使用 PKCE OAuth 和 .NET 从 Xamarin Forms 访问 Dropbox API - 解决方案

Accessing Dropbox from Xamarin Forms using PKCE OAuth and .NET API - solution

可以说,在 Xamarin Forms 中实现 Dropbox 支持很有趣,尤其是使用更安全的 PKCE OAuth 流程,这需要深度链接,因为 WebView 是不安全的。

对于像我一样苦苦挣扎的人,工作代码如下所示,包括共享代码和 Android 代码。我不需要实现 iOS 方面,因为我在那里使用 iCloud 而不是 Dropbox,但这应该是 straightforward.

您可能希望将 ActivityIndi​​cator 添加到调用页面,因为它会在授权期间弹出和弹出视图。

注意:虽然 Xamarin 未正式支持 Dropbox .NET API,但它可以正常工作,如此处所示。

编辑 2021 年 9 月 18 日: 添加代码以 (1) 处理用户拒绝接受访问 Dropbox 的情况和 (2) 在授权后关闭浏览器。一个遗留问题:每次我们授权时,都会在浏览器中添加一个选项卡 - 不知道如何克服它。

ANDROID 代码

using System;
using System.Net;
using System.Threading.Tasks;

using Xamarin.Forms;

using Android.Content;
using Android.App;

using Plugin.CurrentActivity;
using MyApp.Droid.DropboxAuth;
using AndroidX.Activity;

[assembly: Dependency (typeof (DropboxOAuth2_Android))]

namespace MyApp.Droid.DropboxAuth
{
    public class DropboxOAuth2_Android: Activity, IDropbox
    {
        public bool IsBrowserInstalled ()
        // Returns true if a web browser is installed
        {
            string url = "https://google.com";      // Any url will do
            Android.Net.Uri webAddress = Android.Net.Uri.Parse ( url );
            Intent intentWeb = new Intent ( Intent.ActionView, webAddress );
            Context currentContext = CrossCurrentActivity.Current.Activity;
            Android.Content.PM.PackageManager packageManager = currentContext.PackageManager;
            return intentWeb.ResolveActivity ( packageManager ) != null;
        }

        public void OpenBrowser ( string url )
        // Opens default browser
        {
            Intent intent = new Intent ( Intent.ActionView, Android.Net.Uri.Parse ( url ) );
            Context currentContext = CrossCurrentActivity.Current.Activity;
            currentContext.StartActivity ( intent );
        }

        public void CloseBrowser ()
        // Close the browser
        {
            Finish ();
        }
    }
}
using System;

using Android.App;
using Android.Content;
using Android.OS;
using Android.Content.PM;

using MyApp.DropboxService;

namespace MyApp.Droid.DropboxAuth
{
    public class Redirection_Android
    {
        [Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop)]
        [IntentFilter ( new [] { Intent.ActionView },
              Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault },
              DataScheme = "com.mydomain.myapp" )]
        public class RedirectHandler : Activity
        {
            protected async override void OnCreate ( Bundle savedInstanceState )
            {
                base.OnCreate( savedInstanceState );
                Intent intent = Intent;                             // The intent that started this activity
                
                if ( Intent.Action == Intent.ActionView )
                {
                    Android.Net.Uri uri = intent.Data;

                    if ( uri.ToString ().Contains ("The+user+chose+not+to+give+your+app+access" ) )
                    {
                        // User pressed Cancel not Accept
                        if ( MyApp.DropboxService.Authorization.Semaphore != null )
                        {
                            // Release semaphore
                            Behayve.DropboxService.Authorization.Semaphore.Release ();
                            Behayve.DropboxService.Authorization.Semaphore.Dispose ();
                            Behayve.DropboxService.Authorization.Semaphore = null;  
                        }

                        Xamarin.Forms.DependencyService.Get<IDropbox> ().CloseBrowser ();

                        Finish ();
                        return;
                    }

                    if ( uri.GetQueryParameter ( "state" ) != null )
                    { 
                        // Protect from curious eyes
                        if ( uri.GetQueryParameter ( "state" ) != Authorization.StatePKCE )
                            Finish ();

                        if ( uri.GetQueryParameter ( "code" ) != null )
                        {
                            string code = uri.GetQueryParameter ( "code" );                       

                            // Perform stage 2 flow, storing tokens in settings
                            bool success = await Authorization.Stage2FlowAsync ( code );
                            Authorization.IsAuthorizationComplete = true;

                            // Allow shared code that initiated this activity to continue
                            Authorization.Semaphore.Release ();
                        }
                    }
                }

                Finish ();
            }
        }
    }
}

注意:如果目标为 API 30 或更高版本,请将以下内容添加到清单中的 标记内:

<intent>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" />
</intent>

共享代码

using System;

namespace MyApp
{
    public interface IDropbox

    {
        bool IsBrowserInstalled ();                 // True if a browser is installed
        void OpenBrowser ( string url );            // Opens url in internal browser
        void CloseBrowser ();                       // Closes the browser
    }
}
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;

using Xamarin.Forms;

using MyApp.Resx;

using Dropbox.Api;

namespace MyApp.DropboxService
{
    public class Authorization
    {
        private const string packageName = "com.mydomain.myapp";     // Copied from Android manifest
        private const string redirectUri = packageName + ":/oauth2redirect";
        private static PKCEOAuthFlow pkce;
        private const string clientId = “abcabcabcabcabc”;      // From Dropbox app console
        private static DropboxClientConfig dropboxClientConfig;

        // Settings keys
        private const string accessTokenKey = "accessTokenKey";
        public const string refreshTokenKey = "refreshTokenKey";
        private const string userIdKey = "userIdKey";

        public static string StatePKCE {get; private set; }
        public static SemaphoreSlim Semaphore { get; set; }             // Allows shared code to wait for redirect-triggered Android activity to complete
        public static volatile bool IsAuthorizationComplete;            // Authorization is complete, tokens stored in settings

        public Authorization ()
        {
            IsAuthorizationComplete = false;
            Semaphore = new SemaphoreSlim ( 1,1 );
        }

        public async Task<DropboxClient> GetAuthorizedDropBoxClientAsync ()
        // If access tokens not already stored in secure settings, first verifies a browser is installed,
        // then after a browser-based user authorisation dialog, securely stores access token, refresh token and user ID in settings.
        // Returns a long-lived authorised DropboxClient (based on a refresh token stored in settings).
        // Returns null if not authorised or no browser or if user hit Cancel or Back (no token stored).
        // Operations can then be performed on user's Dropbox over time via the DropboxClient. 
        //
        // Assumes caller has verified Internet is available.
        //
        // Employs the PKCE OAuth flow.
        // WebView is not used because of associated security issues -- deep linking is used instead.
        // The tokens can be retrieved from settings any time should they be desired.
        // No auxiliary website is used.
        {
            if ( string.IsNullOrEmpty ( await Utility.GetSettingAsync ( refreshTokenKey ) ) )
            {
                // We do not yet have a refresh key
                try
                {
                    // Verify user has a suitable browser installed
                    if ( ! DependencyService.Get<IDropbox> ().IsBrowserInstalled () )
                    {
                        await App.NavPage.DisplayAlert ( T.NoBrowserInstalled, T.InstallBrowser, T.ButtonOK );
                        return null;
                    }

                    // Stage 1 flow
                    IsAuthorizationComplete = false;
                    DropboxCertHelper.InitializeCertPinning ();
                    pkce = new PKCEOAuthFlow ();                // Generates code verifier and code challenge for PKCE
                    StatePKCE = Guid.NewGuid ().ToString ( "N" );
                    // NOTE: Here authorizeRedirectUI is of the form com.mydomain.myapp:/oauth2redirect
                    Uri authorizeUri = pkce.GetAuthorizeUri ( OAuthResponseType.Code, clientId: clientId, redirectUri:redirectUri,
                                                     state: StatePKCE, tokenAccessType: TokenAccessType.Offline, scopeList: null, includeGrantedScopes: IncludeGrantedScopes.None );

                    // NOTE: authorizeUri looks like this:
                    // https://www.dropbox.com/oauth2/authorize?response_type=code&client_id=abcabcabcabcabc&redirect_uri=com.mydomain.myapp%3A%2Foauth2redirect&state=51cbbd2b7bce4d7990bc72fc95991375&token_access_type=offline&code_challenge_method=S256&code_challenge=r75HUStz-F43vWl2yr9m5ctgF1lgE7uqu-cf_gQpSEU                   

                    // Open authorization url in browser
                    await Semaphore.WaitAsync ();                               // Take semaphore
                    DependencyService.Get<IDropbox> ().OpenBrowser ( authorizeUri.AbsoluteUri );
                   
                    // Wait until Android redirection activity obtains tokens and releases semaphore
                    // NOTE: User might first press Cancel or Back button - this returns user to page calling this method, where OnAppearing will run
                    await Semaphore.WaitAsync ();
                }
                catch
                {
                    if ( Semaphore != null )
                        Semaphore.Dispose ();
                    return null;
                }
            }
            else
                IsAuthorizationComplete = true;

            // Wrap up

            if ( Semaphore != null )
                Semaphore.Dispose ();

            if ( IsAuthorizationComplete )
            {
                // Return authorised Dropbox client
                DropboxClient dropboxClient = await AuthorizedDropboxClientAsync ();

                DependencyService.Get<IDropbox> ().CloseBrowser ();
                return dropboxClient;
            }

            return null;
        }

        public static async Task<bool> Stage2FlowAsync ( string code )
        // Obtains authorization token, refresh token and user Id, and
        // stores them in settings.
        // code = authorization code obtained in stage 1 flow
        // Returns true if tokens obtained
        {
            // Retrieve tokens
            OAuth2Response response = await pkce.ProcessCodeFlowAsync ( code, clientId, redirectUri: redirectUri );
            if ( response == null )
                return false;

            string accessToken = response.AccessToken;
            string refreshToken = response.RefreshToken;
            string userId = response.Uid;

            // Save tokens in settings
            await Utility.SetSettingAsync ( accessTokenKey, accessToken );
            await Utility.SetSettingAsync ( refreshTokenKey, refreshToken );
            await Utility.SetSettingAsync ( userIdKey, userId );

            return true;
        }

        public static async Task<DropboxClient> AuthorizedDropboxClientAsync ( )
        // Returns authorized Dropbox client, or null if none available
        // For use when Dropbox authorization has already taken place
        {
            string refreshToken = await Utility.GetSettingAsync ( Authorization.refreshTokenKey );
            // NOTE: Due to Dropbox.NET API bug for Xamarin, we need to override Android Build HttpClientImplementation setting (AndroidClientHandler) with HTTPClientHandler, for downloads to work
            dropboxClientConfig = new DropboxClientConfig () { HttpClient = new HttpClient ( new HttpClientHandler () ) };
            return new DropboxClient ( refreshToken, clientId, dropboxClientConfig );
        }

        public static async Task ClearTokensInSettingsAsync ()
        // Clears access token, refresh token, user Id token
        // Called when app initialises
        {
            await Utility.SetSettingAsync ( accessTokenKey, string.Empty );
            await Utility.SetSettingAsync ( refreshTokenKey, string.Empty );
            await Utility.SetSettingAsync ( userIdKey, string.Empty );
        }

        public static async Task<bool> IsLoggedInAsync ()
        // Returns true if logged in to Dropbox
        {
            if ( await Utility.GetSettingAsync ( refreshTokenKey ) == string.Empty )
                return false;
            return true;
        }
    }
}
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.IO;
using System.Text;

using Dropbox.Api;
using Dropbox.Api.Files;

using MyApp.Resx;

namespace MyApp.DropboxService
{
    public class FileHelper
    {
    const string _FNF = “~FNF”;

        public static async Task<bool> ExistsAsync ( DropboxClient dbx, string path )
        // Returns true if given filepath/folderpath exists for given Dropbox client
        // Dropbox requires "/" to be the initial character
        { 
            try
            {
                GetMetadataArg getMetadataArg = new GetMetadataArg ( path );
                Metadata xx = await dbx.Files.GetMetadataAsync ( getMetadataArg );
            }
            catch ( Exception ex )
            {
                if ( ex.Message.Contains ( "not_found" ) )      // Seems no other way to do it
                return false;

                await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                throw new Exception ( "In FileHelper.ExistsAsync " + ex.ToString (), ex.InnerException );
            }

            return true;
        }

        public static async Task<CreateFolderResult> CreateFolderAsync ( DropboxClient dbx, string path )
        // Creates folder for given Dropbox user at given path, unless it already exists
        // Returns CreateFolderResult, or null if already exists
        {
            try
            {
                if ( await ExistsAsync ( dbx, path ) )
                    return null;

                CreateFolderArg folderArg = new CreateFolderArg( path );
                return await dbx.Files.CreateFolderV2Async( folderArg );
            }
            catch ( Exception ex )
            {
                await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                throw new Exception ( "In FileHelper.CreateFolderAsync " + ex.ToString (), ex.InnerException );
            }  
        }

        public static async Task DeleteFileAsync ( DropboxClient dbx, string path )
        // Delete given Dropbox user's given file
        {
            try
            {
                DeleteArg deleteArg = new DeleteArg ( path );
                await dbx.Files.DeleteV2Async ( deleteArg );
            }
            catch ( Exception ex )
            {
                await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                throw new Exception  ( "In FileHelper.DeleteFileAsync " + ex.ToString (), ex.InnerException );
            }
        }

        public static async Task<FileMetadata> UploadBinaryFileAsync ( DropboxClient dbx, string localFilepath, string dropboxFilepath )
        // Copies given local binary file to given Dropbox file, deleting any pre-existing destination file
        // NOTE: Dropbox requires initial "/" in dropboxFilePath
        {
            int tries = 0;

            while ( tries < 30 )
            {
                try
                {
                    if ( await ExistsAsync ( dbx, dropboxFilepath ) )
                        await DeleteFileAsync ( dbx, dropboxFilepath );

                    using ( FileStream localStream = new FileStream ( localFilepath, FileMode.Open, FileAccess.Read ) )
                    {
                        return await dbx.Files.UploadAsync ( dropboxFilepath,
                                                             WriteMode.Overwrite.Instance,
                                                             body: localStream );                         
                    }
                }
                catch ( RateLimitException ex )
                {
                    // We have to back off and retry later
                    int backoffSeconds= ex.RetryAfter;      // >= 0
                    System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                    await Task.Delay ( backoffSeconds * 1000 );
                }
                catch ( Exception ex )
                {
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    throw new Exception ( "In FileHelper.UploadBinaryFileAsync " + ex.ToString (), ex.InnerException );
                }
                tries++;
            }
            return null;
        }

        public static async Task<FileMetadata> UploadTextFileAsync ( DropboxClient dbx, string localFilepath, string dropboxFilepath )
        // Copies given local text file to given Dropbox file, deleting any pre-existing destination file
        {
            int tries = 0;

            while ( tries < 30 )
            { 
                try
                {
                    if ( await ExistsAsync ( dbx, dropboxFilepath ) )
                        await DeleteFileAsync ( dbx, dropboxFilepath );

                    string fileContents = File.ReadAllText ( localFilepath );
                    using ( MemoryStream localStream = new MemoryStream ( Encoding.UTF8.GetBytes ( fileContents ) ) )
                    {
                        return await dbx.Files.UploadAsync ( dropboxFilepath,
                                                             WriteMode.Overwrite.Instance,
                                                             body: localStream );
                    }
                }
                catch ( RateLimitException ex )
                {
                    // We have to back off and retry later
                    int backoffSeconds= ex.RetryAfter;      // >= 0
                    System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                    await Task.Delay ( backoffSeconds * 1000 );
                }
                catch ( Exception ex )
                {
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                    throw new Exception ( "In FileHelper.UploadTextFileAsync " + ex.ToString (), ex.InnerException );
                }
                tries++;
            }
            return null;
        }

        public static async Task<bool> DownloadFileAsync ( DropboxClient dbx, string dropboxFilepath, string localFilepath )
        // Copies given Dropbox file to given local file, deleting any pre-existing destination file
        // Returns true if successful
        // NOTE: Dropbox requires initial "/" in dropboxFilePath
        {
            int tries = 0;

            while ( tries < 30 )
            {
                try
                {
                    // If destination exists, delete it
                    if ( File.Exists ( localFilepath ) )
                        File.Delete ( localFilepath );

                    // Copy file
                    using ( var response = await dbx.Files.DownloadAsync ( dropboxFilepath ) )
                    {
                        using ( FileStream fileStream = File.Create ( localFilepath ) )
                        {
                            ( await response.GetContentAsStreamAsync() ).CopyTo ( fileStream );
                        }
                    }
                    return true;
                }
                catch ( RateLimitException ex )
                {
                    // We have to back off and retry later
                    int backoffSeconds= ex.RetryAfter;      // >= 0
                    System.Diagnostics.Debug.WriteLine ( "****** Dropbox requested backoff of " + backoffSeconds.ToString () + " seconds" );
                    await Task.Delay ( backoffSeconds * 1000 );
                }
                catch ( Exception ex )
                {
                    await Utility.WriteLogFileAsync ( T.Exception, ex.AsString () );
                }
                tries++;
            }
            return false;
        }

        public static async Task EnsureSubfolderExistsAsync ( DropboxClient dbx, string subfolderPath )
        // Creates given subfolder for given client unless it already exists
        {
            if ( await ExistsAsync ( dbx, subfolderPath ) )
                return;

            await CreateFolderAsync ( dbx, subfolderPath);
        }
    }
}
using Xamarin.Forms;
using Xamarin.Essentials;

namespace MyApp
{
    public class Utility
    {


        public static async Task SetSettingAsync ( string key, string settingValue )
        // Stores given value in setting whose key is given
        // Uses secure storage if possible, otherwise uses preferences
        {
            try
            {
                await SecureStorage.SetAsync ( key, settingValue );
            }
            catch
            {
                // On some Android devices, secure storage is not supported - here if that is the case
                // Use preferences
                Preferences.Set ( key, settingValue );
            }
        }

        public static async Task<string> GetSettingAsync ( string key )
        // Returns setting with given name, or null if unavailable
        // Uses secure storage if possible, otherwise uses preferences
        {
            string settingValue;

            try
            {
                settingValue = await SecureStorage.GetAsync ( key );
            }
            catch
            {
                // Secure storage is unavailable on this device so use preferences
                settingValue = Preferences.Get ( key, defaultValue: null );
            }

            return settingValue;
        }

Dropbox app console中,权限类型为Scoped App(App Folder),权限为files.content.write和files.content.read。