Clean up oauth structure

This commit is contained in:
Ben Olden-Cooligan 2018-08-06 21:57:33 -04:00
parent dcbc429f8d
commit 32ffe839d8
12 changed files with 282 additions and 289 deletions

View File

@ -1,88 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using NAPS2.Config;
using Newtonsoft.Json.Linq;
namespace NAPS2.ImportExport.Email.Imap
{
public partial class GmailApi : OauthApi, IOauthProvider
{
private const string OAUTH_SCOPE = "https://www.googleapis.com/auth/gmail.compose";
private readonly IUserConfigManager userConfigManager;
private OauthClientCreds creds;
public GmailApi(IUserConfigManager userConfigManager)
{
this.userConfigManager = userConfigManager;
}
public override OauthToken Token => userConfigManager.Config.EmailSetup?.GmailToken;
public string UserId => userConfigManager.Config.EmailSetup?.GmailUser;
public bool HasClientCreds => Creds.ClientId != null;
private OauthClientCreds Creds
{
get
{
if (creds == null)
{
var credObj = JObject.Parse(Encoding.UTF8.GetString(ClientCreds.google_credentials));
var installed = credObj.Value<JObject>("installed");
creds = new OauthClientCreds(installed?.Value<string>("client_id"), installed?.Value<string>("client_secret"));
}
return creds;
}
}
public string OauthUrl(string state, string redirectUri)
{
// TODO: Check tls settings as in FDownloadProgress
return "https://accounts.google.com/o/oauth2/v2/auth?"
+ $"scope={OAUTH_SCOPE}&response_type=code&state={state}&redirect_uri={redirectUri}&client_id={Creds.ClientId}";
}
public OauthToken AcquireToken(string code, string redirectUri)
{
var resp = Post("https://www.googleapis.com/oauth2/v4/token", new NameValueCollection
{
{"code", code},
{"client_id", Creds.ClientId},
{"client_secret", Creds.ClientSecret},
{"redirect_uri", redirectUri},
{"grant_type", "authorization_code"}
});
return new OauthToken
{
AccessToken = resp.Value<string>("access_token"),
RefreshToken = resp.Value<string>("refresh_token"),
Expiry = DateTime.Now.AddSeconds(resp.Value<int>("expires_in"))
};
}
public void RefreshToken()
{
throw new NotImplementedException();
}
public string GetEmail()
{
var resp = Get("https://www.googleapis.com/gmail/v1/users/me/profile");
return resp.Value<string>("emailAddress");
}
public string UploadDraft(string messageRaw)
{
var resp = Post($"https://www.googleapis.com/upload/gmail/v1/users/{UserId}/drafts?uploadType=multipart", messageRaw, "message/rfc822");
return resp.Value<string>("id");
}
}
}

View File

@ -12,12 +12,12 @@ namespace NAPS2.ImportExport.Email.Imap
public class GmailEmailProvider : MimeEmailProvider
{
private readonly IUserConfigManager userConfigManager;
private readonly GmailApi gmailApi;
private readonly GmailOauthProvider gmailOauthProvider;
public GmailEmailProvider(IUserConfigManager userConfigManager, GmailApi gmailApi)
public GmailEmailProvider(IUserConfigManager userConfigManager, GmailOauthProvider gmailOauthProvider)
{
this.userConfigManager = userConfigManager;
this.gmailApi = gmailApi;
this.gmailOauthProvider = gmailOauthProvider;
}
protected string Host => "imap.gmail.com";
@ -38,7 +38,7 @@ namespace NAPS2.ImportExport.Email.Imap
protected override void SendMimeMessage(MimeMessage message)
{
gmailApi.UploadDraft(message.ToString());//Convert.ToBase64String(Encoding.UTF8.GetBytes(message.ToString()))
gmailOauthProvider.UploadDraft(message.ToString());//Convert.ToBase64String(Encoding.UTF8.GetBytes(message.ToString()))
}
}
}

View File

@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using NAPS2.Config;
using Newtonsoft.Json.Linq;
namespace NAPS2.ImportExport.Email.Imap
{
public class GmailOauthProvider : OauthProvider
{
private readonly IUserConfigManager userConfigManager;
private OauthClientCreds creds;
public GmailOauthProvider(IUserConfigManager userConfigManager)
{
this.userConfigManager = userConfigManager;
}
#region Authorization
public override OauthToken Token
{
get => userConfigManager.Config.EmailSetup?.GmailToken;
protected set
{
userConfigManager.Config.EmailSetup = userConfigManager.Config.EmailSetup ?? new EmailSetup();
userConfigManager.Config.EmailSetup.GmailToken = value;
userConfigManager.Save();
}
}
public override string User
{
get => userConfigManager.Config.EmailSetup?.GmailUser;
protected set
{
userConfigManager.Config.EmailSetup = userConfigManager.Config.EmailSetup ?? new EmailSetup();
userConfigManager.Config.EmailSetup.GmailUser = value;
userConfigManager.Save();
}
}
protected override OauthClientCreds Creds
{
get
{
if (creds == null)
{
var credObj = JObject.Parse(Encoding.UTF8.GetString(ClientCreds.google_credentials));
var installed = credObj.Value<JObject>("installed");
creds = new OauthClientCreds(installed?.Value<string>("client_id"), installed?.Value<string>("client_secret"));
}
return creds;
}
}
protected override string Scope => "https://www.googleapis.com/auth/gmail.compose";
protected override string CodeEndpoint => "https://accounts.google.com/o/oauth2/v2/auth";
protected override string TokenEndpoint => "https://www.googleapis.com/oauth2/v4/token";
#endregion
#region Api Methods
protected override string GetUser()
{
var resp = GetAuthorized("https://www.googleapis.com/gmail/v1/users/me/profile");
return resp.Value<string>("emailAddress");
}
public string UploadDraft(string messageRaw)
{
var resp = PostAuthorized($"https://www.googleapis.com/upload/gmail/v1/users/{User}/drafts?uploadType=multipart", messageRaw, "message/rfc822");
return resp.Value<string>("id");
}
#endregion
}
}

View File

@ -1,18 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NAPS2.ImportExport.Email.Imap
{
public interface IOauthProvider
{
OauthToken Token { get; }
string OauthUrl(string state, string redirectUri);
OauthToken AcquireToken(string code, string redirectUri);
void RefreshToken();
}
}

View File

@ -1,55 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net;
using System.Text;
using Newtonsoft.Json.Linq;
namespace NAPS2.ImportExport.Email.Imap
{
public abstract class OauthApi
{
public abstract OauthToken Token { get; }
protected JObject Get(string url)
{
using (var client = ApiClient())
{
string response = client.DownloadString(url);
return JObject.Parse(response);
}
}
protected JObject Post(string url, NameValueCollection values)
{
using (var client = ApiClient())
{
string response = Encoding.UTF8.GetString(client.UploadValues(url, "POST", values));
return JObject.Parse(response);
}
}
protected JObject Post(string url, string body, string contentType)
{
using (var client = ApiClient())
{
client.Headers.Add("Content-Type", contentType);
string response = client.UploadString(url, "POST", body);
return JObject.Parse(response);
}
}
private WebClient ApiClient()
{
var client = new WebClient();
var token = Token;
if (token != null)
{
// TODO: Refresh mechanism
client.Headers.Add("Authorization", $"Bearer {token.AccessToken}");
}
return client;
}
}
}

View File

@ -1,6 +1,10 @@
namespace NAPS2.ImportExport.Email.Imap
using System;
using System.Collections.Generic;
using System.Linq;
namespace NAPS2.ImportExport.Email.Imap
{
internal class OauthClientCreds
public class OauthClientCreds
{
public OauthClientCreds(string clientId, string clientSecret)
{

View File

@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NAPS2.Util;
using Newtonsoft.Json.Linq;
namespace NAPS2.ImportExport.Email.Imap
{
public abstract class OauthProvider
{
public abstract OauthToken Token { get; protected set; }
public abstract string User { get; protected set; }
protected abstract OauthClientCreds Creds { get; }
protected abstract string CodeEndpoint { get; }
protected abstract string TokenEndpoint { get; }
protected abstract string Scope { get; }
public void AcquireToken(CancellationToken cancelToken)
{
// Initialize state, port, and redirectUri
byte[] buffer = new byte[16];
SecureStorage.CryptoRandom.Value.GetBytes(buffer);
string state = string.Join("", buffer.Select(b => b.ToString("x")));
// There's a possible race condition here with the port, but meh
int port = GetUnusedPort();
var redirectUri = $"http://127.0.0.1:{port}/";
// Listen on the redirect uri for the code
var listener = new HttpListener();
listener.Prefixes.Add(redirectUri);
listener.Start();
// Abort the listener if the user cancels
cancelToken.Register(() => listener.Abort());
cancelToken.ThrowIfCancellationRequested();
// TODO: Catch exception on abort
// Open the user interface (which will redirect to our localhost listener)
var url = $"{CodeEndpoint}?scope={Scope}&response_type=code&state={state}&redirect_uri={redirectUri}&client_id={Creds.ClientId}";
Process.Start(url);
// Wait for the authorization code to be sent to the local socket
string code;
while (true)
{
var ctx = listener.GetContext();
var queryString = ctx.Request.QueryString;
string responseString = "<script>location.href = 'about:blank';</script>";
byte[] responseBytes = Encoding.UTF8.GetBytes(responseString);
var response = ctx.Response;
response.ContentLength64 = responseBytes.Length;
response.OutputStream.Write(responseBytes, 0, responseBytes.Length);
response.OutputStream.Close();
// Validate the state (standard oauth2 security)
string requestState = queryString.Get("state");
if (requestState == state)
{
// Yay, we got an authorization code
code = queryString.Get("code");
break;
}
}
listener.Stop();
cancelToken.ThrowIfCancellationRequested();
// Trade the code in for a token
var resp = PostAuthorized(TokenEndpoint, new NameValueCollection
{
{"code", code},
{"client_id", Creds.ClientId},
{"client_secret", Creds.ClientSecret},
{"redirect_uri", redirectUri},
{"grant_type", "authorization_code"}
});
Token = new OauthToken
{
AccessToken = resp.Value<string>("access_token"),
RefreshToken = resp.Value<string>("refresh_token"),
Expiry = DateTime.Now.AddSeconds(resp.Value<int>("expires_in"))
};
// Get the user id
User = GetUser();
}
private static int GetUnusedPort()
{
var listener = new TcpListener(IPAddress.Any, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
protected abstract string GetUser();
public void RefreshToken()
{
throw new NotImplementedException();
}
protected JObject Get(string url)
{
using (var client = new WebClient())
{
string response = client.DownloadString(url);
return JObject.Parse(response);
}
}
protected JObject GetAuthorized(string url)
{
using (var client = AuthorizedClient())
{
string response = client.DownloadString(url);
return JObject.Parse(response);
}
}
protected JObject PostAuthorized(string url, NameValueCollection values)
{
using (var client = AuthorizedClient())
{
string response = Encoding.UTF8.GetString(client.UploadValues(url, "POST", values));
return JObject.Parse(response);
}
}
protected JObject PostAuthorized(string url, string body, string contentType)
{
using (var client = AuthorizedClient())
{
client.Headers.Add("Content-Type", contentType);
string response = client.UploadString(url, "POST", body);
return JObject.Parse(response);
}
}
private WebClient AuthorizedClient()
{
var client = new WebClient();
var token = Token;
if (token != null)
{
// TODO: Refresh mechanism
client.Headers.Add("Authorization", $"Bearer {token.AccessToken}");
}
return client;
}
}
}

View File

@ -143,11 +143,10 @@
<Compile Include="ImportExport\Email\EmailProviderType.cs" />
<Compile Include="ImportExport\Email\EmailSetup.cs" />
<Compile Include="ImportExport\Email\IEmailProviderFactory.cs" />
<Compile Include="ImportExport\Email\Imap\GmailApi.cs" />
<Compile Include="ImportExport\Email\Imap\GmailOauthProvider.cs" />
<Compile Include="ImportExport\Email\Imap\GmailEmailProvider.cs" />
<Compile Include="ImportExport\Email\Imap\IOauthProvider.cs" />
<Compile Include="ImportExport\Email\Imap\MimeEmailProvider.cs" />
<Compile Include="ImportExport\Email\Imap\OauthApi.cs" />
<Compile Include="ImportExport\Email\Imap\OauthProvider.cs" />
<Compile Include="ImportExport\Email\Imap\OauthClientCreds.cs" />
<Compile Include="ImportExport\Email\Imap\OauthToken.cs" />
<Compile Include="ImportExport\IAutoSave.cs" />

View File

@ -30,7 +30,6 @@
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FAuthorize));
this.lblWaiting = new System.Windows.Forms.Label();
this.linkTryAgain = new System.Windows.Forms.LinkLabel();
this.btnCancel = new System.Windows.Forms.Button();
this.SuspendLayout();
//
@ -39,13 +38,6 @@
resources.ApplyResources(this.lblWaiting, "lblWaiting");
this.lblWaiting.Name = "lblWaiting";
//
// linkTryAgain
//
resources.ApplyResources(this.linkTryAgain, "linkTryAgain");
this.linkTryAgain.Name = "linkTryAgain";
this.linkTryAgain.TabStop = true;
this.linkTryAgain.LinkClicked += new System.Windows.Forms.LinkLabelLinkClickedEventHandler(this.linkTryAgain_LinkClicked);
//
// btnCancel
//
resources.ApplyResources(this.btnCancel, "btnCancel");
@ -59,7 +51,6 @@
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.btnCancel;
this.Controls.Add(this.btnCancel);
this.Controls.Add(this.linkTryAgain);
this.Controls.Add(this.lblWaiting);
this.Name = "FAuthorize";
this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.FAuthorize_FormClosed);
@ -72,7 +63,6 @@
#endregion
private System.Windows.Forms.Label lblWaiting;
private System.Windows.Forms.LinkLabel linkTryAgain;
private System.Windows.Forms.Button btnCancel;
}
}

View File

@ -9,6 +9,7 @@ using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using NAPS2.ImportExport.Email.Imap;
using NAPS2.Util;
@ -19,9 +20,7 @@ namespace NAPS2.WinForms
{
private readonly ThreadFactory threadFactory;
private string state;
private int port;
private HttpListener listener;
private CancellationTokenSource cancelTokenSource;
public FAuthorize(ThreadFactory threadFactory)
{
@ -31,92 +30,34 @@ namespace NAPS2.WinForms
InitializeComponent();
}
public IOauthProvider OauthProvider { get; set; }
public OauthToken Token { get; private set; }
public OauthProvider OauthProvider { get; set; }
private void FAuthorize_Load(object sender, EventArgs e)
{
MaximumSize = new Size(Math.Max(lblWaiting.Width + 142, 272), Height);
MinimumSize = new Size(Math.Max(lblWaiting.Width + 142, 272), Height);
InitState();
OpenSocket();
OpenOauthUrl();
}
private void InitState()
{
byte[] buffer = new byte[16];
SecureStorage.CryptoRandom.Value.GetBytes(buffer);
state = string.Join("", buffer.Select(b => b.ToString("x")));
// There's a possible race condition here with the port, but meh
port = GetUnusedPort();
}
private void linkTryAgain_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
{
listener.Abort();
OpenSocket();
OpenOauthUrl();
}
private void OpenSocket()
{
cancelTokenSource = new CancellationTokenSource();
threadFactory.StartThread(() =>
{
listener = new HttpListener();
listener.Prefixes.Add(RedirectUri);
listener.Start();
while (true)
try
{
var ctx = listener.GetContext();
var queryString = ctx.Request.QueryString;
string responseString = "<script>location.href = 'about:blank';</script>";
byte[] responseBytes = Encoding.UTF8.GetBytes(responseString);
var response = ctx.Response;
response.ContentLength64 = responseBytes.Length;
response.OutputStream.Write(responseBytes, 0, responseBytes.Length);
response.OutputStream.Close();
string requestState = queryString.Get("state");
if (requestState == state)
OauthProvider.AcquireToken(cancelTokenSource.Token);
Invoke(() =>
{
string code = queryString.Get("code");
Token = OauthProvider.AcquireToken(code, RedirectUri);
break;
}
DialogResult = DialogResult.OK;
Close();
});
}
listener.Stop();
Invoke(() =>
catch (OperationCanceledException)
{
DialogResult = DialogResult.OK;
Close();
});
}
});
}
private string RedirectUri => $"http://127.0.0.1:{port}/";
private void OpenOauthUrl()
{
Process.Start(OauthProvider.OauthUrl(state, RedirectUri));
}
private static int GetUnusedPort()
{
var listener = new TcpListener(IPAddress.Any, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private void FAuthorize_FormClosed(object sender, FormClosedEventArgs e)
{
listener?.Abort();
cancelTokenSource?.Cancel();
}
}
}

View File

@ -123,7 +123,7 @@
</data>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="lblWaiting.Location" type="System.Drawing.Point, System.Drawing">
<value>12, 9</value>
<value>12, 17</value>
</data>
<data name="lblWaiting.Size" type="System.Drawing.Size, System.Drawing">
<value>130, 13</value>
@ -144,33 +144,6 @@
<value>$this</value>
</data>
<data name="&gt;&gt;lblWaiting.ZOrder" xml:space="preserve">
<value>2</value>
</data>
<data name="linkTryAgain.AutoSize" type="System.Boolean, mscorlib">
<value>True</value>
</data>
<data name="linkTryAgain.Location" type="System.Drawing.Point, System.Drawing">
<value>12, 25</value>
</data>
<data name="linkTryAgain.Size" type="System.Drawing.Size, System.Drawing">
<value>51, 13</value>
</data>
<data name="linkTryAgain.TabIndex" type="System.Int32, mscorlib">
<value>1</value>
</data>
<data name="linkTryAgain.Text" xml:space="preserve">
<value>Try again</value>
</data>
<data name="&gt;&gt;linkTryAgain.Name" xml:space="preserve">
<value>linkTryAgain</value>
</data>
<data name="&gt;&gt;linkTryAgain.Type" xml:space="preserve">
<value>System.Windows.Forms.LinkLabel, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
<data name="&gt;&gt;linkTryAgain.Parent" xml:space="preserve">
<value>$this</value>
</data>
<data name="&gt;&gt;linkTryAgain.ZOrder" xml:space="preserve">
<value>1</value>
</data>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
@ -536,6 +509,6 @@
<value>FAuthorize</value>
</data>
<data name="&gt;&gt;$this.Type" xml:space="preserve">
<value>NAPS2.WinForms.FormBase, NAPS2.Core, Version=5.8.2.41756, Culture=neutral, PublicKeyToken=null</value>
<value>NAPS2.WinForms.FormBase, NAPS2.Core, Version=5.8.2.37340, Culture=neutral, PublicKeyToken=null</value>
</data>
</root>

View File

@ -15,17 +15,15 @@ namespace NAPS2.WinForms
{
public partial class FEmailProvider : FormBase
{
private readonly IEmailProviderFactory emailProviderFactory;
private readonly GmailApi gmailApi;
private readonly GmailOauthProvider gmailOauthProvider;
private List<EmailProviderWidget> providerWidgets;
private string[] systemClientNames;
private string defaultSystemClientName;
public FEmailProvider(IEmailProviderFactory emailProviderFactory, GmailApi gmailApi)
public FEmailProvider(GmailOauthProvider gmailOauthProvider)
{
this.emailProviderFactory = emailProviderFactory;
this.gmailApi = gmailApi;
this.gmailOauthProvider = gmailOauthProvider;
InitializeComponent();
}
@ -93,14 +91,12 @@ namespace NAPS2.WinForms
private void ChooseGmail()
{
var authForm = FormFactory.Create<FAuthorize>();
authForm.OauthProvider = gmailApi;
authForm.OauthProvider = gmailOauthProvider;
authForm.ShowDialog();
if (authForm.DialogResult == DialogResult.OK)
{
var setup = GetOrCreateSetup();
setup.ProviderType = EmailProviderType.Gmail;
setup.GmailToken = authForm.Token;
setup.GmailUser = gmailApi.GetEmail();
UserConfigManager.Save();
DialogResult = DialogResult.OK;
Close();