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



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

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

您可能需要将ActivityIndicator添加到调用页面,因为它在授权期间会弹出或弹出视图。

注意:当Dropbox。NET API不是Xamarin的官方支持,它可以工作,如图所示。

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应用控制台中,权限类型为Scoped app(应用文件夹(,权限为files.content.write和files.content-read.

最新更新