OAuth 2.0 Authorization for windows desktop application using HttpListener

99封情书 提交于 2021-02-11 18:22:42

问题


I am writing a windows desktop application with External Authentication(Google, Facebook) in C#.

I'm using HttpListener to allow a user to get Barer token by External Authentication Service with ASP.NET Web API, but administrator privileges are required for that and I want run without admin mode.

My reference was Sample Desktop Application for Windows.

Is this the best practice for external authentication provider from C#? Or is there another way to do that?

This is my code to get Barer token by external provider:

public static async Task<string> RequestExternalAccessToken(string provider)
{
    // Creates a redirect URI using an available port on the loopback address.
    string redirectURI = string.Format("http://{0}:{1}/", IPAddress.Loopback, GetRandomUnusedPort());

    // Creates an HttpListener to listen for requests on that redirect URI.
    var http = new HttpListener();
    http.Prefixes.Add(redirectURI);
    http.Start();

    // Creates the OAuth 2.0 authorization request.
    string authorizationRequest = Properties.Settings.Default.Server
        + "/api/Account/ExternalLogin?provider="
        + provider
        + "&response_type=token&client_id=desktop"
        + "&redirect_uri="
        + redirectURI + "?";

    // Opens request in the browser.
    System.Diagnostics.Process.Start(authorizationRequest);

    // Waits for the OAuth authorization response.
    var context = await http.GetContextAsync();

    // Sends an HTTP response to the browser.
    var response = context.Response;
    string responseString = string.Format("<html><head></head><body></body></html>");
    var buffer = System.Text.Encoding.UTF8.GetBytes(responseString);
    response.ContentLength64 = buffer.Length;
    var responseOutput = response.OutputStream;
    Task responseTask = responseOutput.WriteAsync(buffer, 0, buffer.Length).ContinueWith((task) =>
    {
        responseOutput.Close();
        http.Stop();
        Console.WriteLine("HTTP server stopped.");
    });

    // Checks for errors.
    if (context.Request.QueryString.Get("access_token") == null)
    {
        throw new ApplicationException("Error connecting to server");
    }

    var externalToken = context.Request.QueryString.Get("access_token");

    var path = "/api/Account/GetAccessToken";

    var client = new RestClient(Properties.Settings.Default.Server + path);
    RestRequest request = new RestRequest() { Method = Method.GET };
    request.AddParameter("provider", provider);
    request.AddParameter("AccessToken", externalToken);
    request.AddHeader("Content-Type", "application/x-www-form-urlencoded");

    var clientResponse = client.Execute(request);

    if (clientResponse.StatusCode == HttpStatusCode.OK)
    {
        var responseObject = JsonConvert.DeserializeObject<dynamic>(clientResponse.Content);

        return responseObject.access_token;
    }
    else
    {
        throw new ApplicationException("Error connecting to server", clientResponse.ErrorException);
    }
}

回答1:


I don't know about Facebook, but usually (I am experienced with Google OAuth2 and Azure AD as well as Azure AD B2C), the authentication provider allows you to use a custom URI scheme for the authentication callback, something like badcompany://auth

To acquire an authentication token I ended up implementing the following scheme (All code is presented without warranty and not to be copied thoughtlessly.)

1. Register an URI-handler when the app is started

You can register an URI-Handler by creating a key in the HKEY_CURRENT_USER/Software/Classes (hence no admin privileges needed) key in the Windows registry

  • The name of the key equals the URI prefix, badcompany in our case
  • The key contains an empty string value named URL Protocol
  • The key contains a subkey DefaultIcon for the icon (actually I do not know whether this is necessary), I used the path of the current executable
  • There is a subkey shell/open/command, whose default value determines the path of the command to execute when the URI is tried to be opened, **please note*, that the "%1" is necessary to pass the URI to the executable
    this.EnsureKeyExists(Registry.CurrentUser, "Software/Classes/badcompany", "URL:BadCo Applications");
    this.SetValue(Registry.CurrentUser, "Software/Classes/badcompany", "URL Protocol", string.Empty);
    this.EnsureKeyExists(Registry.CurrentUser, "Software/Classes/badcompany/DefaultIcon", $"{location},1");
    this.EnsureKeyExists(Registry.CurrentUser, "Software/Classes/badcompany/shell/open/command", $"\"{location}\" \"%1\"");

// ...

private void SetValue(RegistryKey rootKey, string keys, string valueName, string value)
{
    var key = this.EnsureKeyExists(rootKey, keys);
    key.SetValue(valueName, value);
}

private RegistryKey EnsureKeyExists(RegistryKey rootKey, string keys, string defaultValue = null)
{
    if (rootKey == null)
    {
        throw new Exception("Root key is (null)");
    }

    var currentKey = rootKey;
    foreach (var key in keys.Split('/'))
    {
        currentKey = currentKey.OpenSubKey(key, RegistryKeyPermissionCheck.ReadWriteSubTree) 
                     ?? currentKey.CreateSubKey(key, RegistryKeyPermissionCheck.ReadWriteSubTree);

        if (currentKey == null)
        {
            throw new Exception("Could not get or create key");
        }
    }

    if (defaultValue != null)
    {
        currentKey.SetValue(string.Empty, defaultValue);
    }

    return currentKey;
}

2. Open a pipe for IPC

Since you'll have to pass messages from one instance of your program to another, you'll have to open a named pipe that can be used for that purpose.

I called this code in a loop in a background Task

private async Task<string> ReceiveTextFromPipe(CancellationToken cancellationToken)
{
    string receivedText;

    PipeSecurity ps = new PipeSecurity();
    System.Security.Principal.SecurityIdentifier sid = new System.Security.Principal.SecurityIdentifier(System.Security.Principal.WellKnownSidType.WorldSid, null);
    PipeAccessRule par = new PipeAccessRule(sid, PipeAccessRights.ReadWrite, System.Security.AccessControl.AccessControlType.Allow);
    ps.AddAccessRule(par);

    using (var pipeStream = new NamedPipeServerStream(this._pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Message, PipeOptions.Asynchronous, 4096, 4096, ps))
    {
        await pipeStream.WaitForConnectionAsync(cancellationToken);

        using (var streamReader = new StreamReader(pipeStream))
        {
            receivedText = await streamReader.ReadToEndAsync();
        }
    }

    return receivedText;
}

3. Make sure that the application is started only once

This can be acquired using a Mutex.

internal class SingleInstanceChecker
{
    private static Mutex Mutex { get; set; }

    public static async Task EnsureIsSingleInstance(string id, Action onIsSingleInstance, Func<Task> onIsSecondaryInstance)
    {
        SingleInstanceChecker.Mutex = new Mutex(true, id, out var isOnlyInstance);
        if (!isOnlyInstance)
        {
            await onIsSecondaryInstance();
            Application.Current.Shutdown(0);
        }
        else
        {
            onIsSingleInstance();
        }
    }
}

When the mutex has been acquired by another instance, the application is not fully started, but

4. Handle being called with the authentication redirect URI

  1. If it's the only (first) instance, it may handle the authentication redirect URI itself
    • Extract the token from the URI
    • Store the token (if necessary and/or wanted)
    • Use the token for requests
  2. If it's a further instance
    • Pass the redirect URI to the first instance by using pipes
    • The first instance now performs the steps under 1.
    • Close the second instance

The URI is sent to the first instance with

using (var client = new NamedPipeClientStream(this._pipeName))
{
    try
    {
        var millisecondsTimeout = 2000;
        await client.ConnectAsync(millisecondsTimeout);
    }
    catch (Exception)
    {
        onSendFailed();
        return;
    }

    if (!client.IsConnected)
    {
        onSendFailed();
    }

    using (StreamWriter writer = new StreamWriter(client))
    {
        writer.Write(stringToSend);
        writer.Flush();
    }
}



回答2:


To add to Paul's excellent answer:

  • Identity Model Libraries are worth looking at - one of the things they'll do for you is Authorization Code Flow (PKCE) which is recommended for native apps
  • My preference is the same as Paul's - to use custom URI schemes - usability is better I think
  • Having said that, a loopback solution should work without admin rights for ports greater than 1024

If it helps there is some stuff on my blog about this - including a Nodejs / Electron sample you can run from here to see what a finished solution looks like.



来源:https://stackoverflow.com/questions/60545581/oauth-2-0-authorization-for-windows-desktop-application-using-httplistener

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!