不透過第三方元件,完整實作OAuth2.0之存取過程

OAuth2.0在當下已經是各大型網站認證的標準,各大型網站也都會幫各Server端的語言、Client端的環境推出各式各樣的程式庫(Library),舉Google的來說,你只是想要驗證並取得使用者的Email,就可能要用NuGet下載四五個以上的DLL才可以,對於愛乾淨的人來說簡直是刺眼到了極點。因此,這篇文章就是想要透過一步一腳印的方式,親自走完所有OAuth2.0的流程,並且取得使用者的相關資訊。

這裡要先聲明,本篇文章中的程式碼,都是我自己對於OAuth2.0的概念性證明(Proof of Concept),由其是類別中有很多沒有考慮Exception或者是罕見狀況的事,所以不建議用在正式用途上,如果要套用到正式用途的話,一定要再加以大幅修正才是。

OAuth2.0的流程

在這邊先用文字簡列一下OAuth2.0的認證流程:

再次強調,當然還有很多未考慮的狀況,例如有AccessToken但是RefreshToken卻不見了這種奇怪的狀況,這些都要再特別加工處理,但是不在本POC的考慮範圍內就是了。

建立一個OAuth2.0的存取類別,以及要跟Google端交握JSON的各式類別

因為本POC最終是要在Console端上運行,因此為了化簡程式碼,我們會在登錄檔上面建立三個機碼,來當作是我們小型的資料庫。最後登錄檔的狀況將會如下:

OAuth2.0類別與各方法、欄位之代表意義,請見下列程式碼與詳細註解:

namespace OAuth2Testing
{
	public class OAuth2
	{
		//透過GoogleDeveloperConsole取得的JSON檔案資訊
		public OfficialData OfficialData;
		//透過Google取得之Token資訊
		public TokenData TokenData;
		//寫在註冊檔中的機碼名稱,當作資料庫用
		private string _cRegistryPath = @".DEFAULT\SOFTWARE\GoogleOAuth2";
		private string _cRegKeyAccessToken = "cAccessToken";
		private string _cRegKeyRefreshToken = "cRefreshToken";
		private string _cRegKeyTokenExpiryDate = "dTokenExpiryDate";
		//存取登錄檔的主要物件
		private Microsoft.Win32.RegistryKey _oRK;

		/// <summary>
		/// 載入從GoogleDeveloperConsole取得的JSON檔案
		/// </summary>
		/// <param name="cFilePathAndName">JSON檔案路徑與檔名</param>
		public OAuth2(string cFilePathAndName)
		{
			//讀取GDC給予的client.json
			Func<string> fnReadFile = () =>
			{
				using (System.IO.StreamReader oSR = new System.IO.StreamReader(cFilePathAndName))
				{ return oSR.ReadToEnd(); }
			};
			dynamic oJsonTemp = Newtonsoft.Json.JsonConvert.DeserializeObject(fnReadFile());
			OfficialData = Newtonsoft.Json.JsonConvert.DeserializeObject<OfficialData>(oJsonTemp.installed.ToString());
			//建立註冊檔存取物件
			_oRK = Microsoft.Win32.Registry.Users.CreateSubKey(_cRegistryPath);
			_oRK = Microsoft.Win32.Registry.Users.OpenSubKey(_cRegistryPath, true);
			//如果是第一次,連註冊檔機碼都讀不到,那就預建一些必要鍵值
			if (
				string.IsNullOrWhiteSpace(System.Convert.ToString(_oRK.GetValue(_cRegKeyAccessToken))) ||
				string.IsNullOrWhiteSpace(System.Convert.ToString(_oRK.GetValue(_cRegKeyRefreshToken))) ||
				string.IsNullOrWhiteSpace(System.Convert.ToString(_oRK.GetValue(_cRegKeyTokenExpiryDate))))
			{
				_oRK.SetValue(_cRegKeyAccessToken, "Preparation", Microsoft.Win32.RegistryValueKind.String);
				_oRK.SetValue(_cRegKeyRefreshToken, "Preparation", Microsoft.Win32.RegistryValueKind.String);
				_oRK.SetValue(_cRegKeyTokenExpiryDate, System.DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), Microsoft.Win32.RegistryValueKind.String);
			}
			//因為不可能每一次程式跑起來,都是從頭叫使用者認證一次,因此預先把一些必要的TokenData載入到實體中是必要的
			//因為這是Proof Of Concept,因此「不考慮AccessToken存在,但是RefreshToken不存在」,或其它很罕見的狀況
			TokenData = new TokenData()
			{
				access_token = _oRK.GetValue(_cRegKeyAccessToken).ToString(),
				refresh_token = _oRK.GetValue(_cRegKeyRefreshToken).ToString()
			};
		}

		/// <summary>
		/// 是否存在AccessToken
		/// </summary>
		/// <returns>true存在;false不存在</returns>
		public bool GetHadAccessToken()
		{
			if (
				_oRK.GetValue(_cRegKeyAccessToken).ToString() == "Preparation" ||
				_oRK.GetValue(_cRegKeyRefreshToken).ToString() == "Preparation")
			{ return false; }
			else
			{ return true; }
		}

		/// <summary>
		/// 是否需要進行Token更新
		/// 為了怕本機與Google的時間不一致,安全起見下先預扣Token到期時間3分鐘
		/// </summary>
		/// <returns>true需要更新;false不需要更新</returns>
		public bool GetNeedRefreshToken()
		{
			//判斷是否過期
			if (
				System.DateTime.Now.CompareTo(
					System.Convert.ToDateTime(
						_oRK.GetValue(_cRegKeyTokenExpiryDate)
					).AddMinutes(-3)) >= 0 ||
				_oRK.GetValue(_cRegKeyRefreshToken).ToString() == "Preparation")
			{ return true; }
			else
			{ return false; }
		}

		/// <summary>
		/// 組合授權網址,以利調用瀏覽器進行視覺化驗證
		/// </summary>
		/// <returns>回傳授權網址</returns>
		public string GetAutenticationURI()
		{
			//調用GoogleAPI的使用範圍
			//profile >> https://www.googleapis.com/auth/userinfo.profile
			//email >> https://www.googleapis.com/auth/userinfo.email
			string _cScopes = @"profile email";
			//回傳授權網址
			return System.Uri.EscapeUriString(
				string.Format("{0}?client_id={1}&redirect_uri={2}&scope={3}&response_type=code",
					OfficialData.auth_uri,
					OfficialData.client_id,
					OfficialData.redirect_uris[0],
					_cScopes
				)
			);
		}

		/// <summary>
		/// 取得AccessToken
		/// </summary>
		/// <param name="cAuthCodeFromBrowser">由瀏覽器傳回來的授權碼</param>
		public void GetToken(string cAuthCodeFromBrowser)
		{
			using (System.Net.WebClient oWC = new System.Net.WebClient())
			{
				//組建要上傳的資料集
				System.Collections.Specialized.NameValueCollection oNC = new System.Collections.Specialized.NameValueCollection();
				oNC.Add("code", cAuthCodeFromBrowser);
				oNC.Add("client_id", OfficialData.client_id);
				oNC.Add("client_secret", OfficialData.client_secret);
				oNC.Add("redirect_uri", OfficialData.redirect_uris[0]);
				oNC.Add("grant_type", "authorization_code");
				//上傳並取回回傳值,轉換丟到TokenData
				oWC.Encoding = System.Text.Encoding.ASCII;
				try
				{
					TokenData = Newtonsoft.Json.JsonConvert.DeserializeObject<TokenData>(
						System.Text.Encoding.ASCII.GetString(
							oWC.UploadValues(OfficialData.token_uri, oNC)
						)
					);
				}
				catch
				{ throw new System.Exception("所輸入的授權碼有誤,驗證失敗。"); }
				//更新註冊檔變數
				SaveToRegistry();
			}
		}

		/// <summary>
		/// 強制更新Token
		/// </summary>
		public void RefreshToken()
		{
			using (System.Net.WebClient oWC = new System.Net.WebClient())
			{
				//組建要上傳的資料集
				System.Collections.Specialized.NameValueCollection oNC = new System.Collections.Specialized.NameValueCollection();
				oNC.Add("client_id", OfficialData.client_id);
				oNC.Add("client_secret", OfficialData.client_secret);
				string cTemp = TokenData.refresh_token;
				oNC.Add("refresh_token", cTemp);
				oNC.Add("grant_type", "refresh_token");
				//上傳並取回回傳值,轉換丟到TokenData
				oWC.Encoding = System.Text.Encoding.ASCII;
				try
				{
					TokenData = Newtonsoft.Json.JsonConvert.DeserializeObject<TokenData>(
						System.Text.Encoding.ASCII.GetString(
							oWC.UploadValues(OfficialData.token_uri, oNC)
						)
					);
					//因為Google在進行RefreshToken更新在回傳JSON資料時,會刻意的把RefreshToken清空,因此程式在這邊把值覆寫回去
					TokenData.refresh_token = cTemp;
				}
				catch
				{ throw new System.Exception("所輸入的授權碼有誤,驗證失敗。"); }
				//更新註冊檔變數
				SaveToRegistry();
			}
		}

		/// <summary>
		/// 將得到的Token資訊記錄到註冊檔中
		/// </summary>
		private void SaveToRegistry()
		{
			_oRK.SetValue(_cRegKeyAccessToken, TokenData.access_token, Microsoft.Win32.RegistryValueKind.String);
			_oRK.SetValue(_cRegKeyRefreshToken, TokenData.refresh_token, Microsoft.Win32.RegistryValueKind.String);
			_oRK.SetValue(_cRegKeyTokenExpiryDate, System.DateTime.Now.AddSeconds(TokenData.expires_in).ToString("yyyy/MM/dd HH:mm:ss"), Microsoft.Win32.RegistryValueKind.String);
		}
	}

	/// <summary>
	/// 儲存來自ClientJson的資料
	/// </summary>
	public class OfficialData
	{
		public string client_id { get; set; }
		public string auth_uri { get; set; }
		public string token_uri { get; set; }
		public string client_secret { get; set; }
		public string[] redirect_uris { get; set; }
	}

	/// <summary>
	/// 儲存與Google進行交握Token時的資料
	/// </summary>
	public class TokenData
	{
		public string access_token { get; set; }
		public string token_type { get; set; }
		public int expires_in { get; set; }
		public string id_token { get; set; }
		public string refresh_token { get; set; }
	}
}

主要OAuth2.0控制程序的實作

我們刻意將OAuth2.0的類別中的流程自動判斷與控制部份全部取消,而將主要的控制權全部交給Main程序中來處理,如此一來,我們可以透過程式來完整觀察出OAuth2.0運行的機制。要注意的是,從GoogleDeveloperConsole拿到的JSON檔案,請儲存到C:\OAuth2.json中。

static void Main(string[] args)
{
	Console.WriteLine("--- Google OAuth2 Test ---");
	OAuth2 oAuth2 = new OAuth2(@"C:\OAuth2.json");
	
	//如果根本沒有AccessToken的話(通常發生在一開始,或者是使用者做到互動授權時就跳開了)
	if (!oAuth2.GetHadAccessToken())
	{
		Console.WriteLine("系統偵測到沒有任何您允許的存取權,請在瀏覽器中「接受」授權存取權。");
		//開啟瀏覽器與使用者進行互動式驗証
		System.Diagnostics.Process.Start("FireFox.exe", oAuth2.GetAutenticationURI());
		Console.Write("請複製網頁中的授權碼並貼到此處:");
		string cAuthCodeFromBrowser = Console.ReadLine();
		//進行Token的取得
		try
		{
			oAuth2.GetToken(cAuthCodeFromBrowser);
			Console.WriteLine("* 已經取得到您允許的存取權 *");
		}
		catch (System.Exception ex)
		{
			Console.WriteLine(ex.Message);
			return;
		}
	}

	//是否有需要進行授權的更新
	if (oAuth2.GetNeedRefreshToken())
	{
		Console.WriteLine("您的存取權已經失效,系統正在進行權杖更新!");
		oAuth2.RefreshToken();
	}
	else
	{
		Console.WriteLine("您的存取權目前仍然有效,不需要進行權杖更新!");
	}

	Console.WriteLine();
	Console.WriteLine("-------------------------");
	Console.WriteLine("Google OAuth2 驗證已經通過,正在取用使用者帳號資訊");
	Console.WriteLine("-------------------------");

	//隨便弄個匿名型別當ORM
	var oData = new { name = "name", picture = "picture", email = "email", id = "id", verified_email = false };

	//存取Google OAuth2 API ver.2: userinfo
	using (System.Net.WebClient oWC = new System.Net.WebClient())
	{
		oWC.Encoding = System.Text.Encoding.UTF8;
		try
		{
			oData = Newtonsoft.Json.JsonConvert.DeserializeAnonymousType(
				oWC.DownloadString(
					string.Format(
						"https://www.googleapis.com/oauth2/v2/userinfo?access_token={0}",
						oAuth2.TokenData.access_token
					)
				), 
				oData
			);
		}
		catch
		{
			Console.WriteLine(string.Format("取得使用者相關資訊過程失敗,有可能是使用者已經將這個應用程式移除。AccessToken={0}。", oAuth2.TokenData.access_token));
			return;
		}
	}

	//存取成功的話就印出
	Console.WriteLine(string.Format(
		"Name: {0} \nPicture: {1} \nEmail: {2} \nID: {3} \nVerifiedEmail: {4}",
		oData.name,
		oData.picture,
		oData.email,
		oData.id,
		oData.verified_email
	));

	Console.Read();
}

*程式運行結果a:第一次運行,需要透過瀏覽器進行互動式驗證

通過授權後,就可以取得AccessToken了

*程式運行結果b:正常運行

*程式運行結果c:AccessToken失效,進行RefreshToken更新

特別注意下列事項

  1. 只要把GetAutenticationURI真正透過瀏覽器重新送出,哪怕是沒有在介面上點同意/允許,更別提把授權碼貼回來程式中的動作根本沒做。總之做了這個動作後,之前所有的AuthCodeFromBrowser、AccessToken與RefreshToken都沒效了。
  2. Google已經把Profile跟Email全部跟Google+綁在一起,所以如果你取得的JSON裡面,怎樣就是看不到email欄位資訊,那麼,你一定是沒有把你與你的公開 Google+個人資料建立關聯。最簡單的方式,就是去Google+建立起帳戶關聯,並允許相關的存取權限後,再回來看JSON就會有email帳號了(就算事後再去把Google+個人資料砍掉,依然會看到email)。這些資訊是Google從來不會明明白白的告訴你的。

※ 相關參考

透過Google Developer Console建立專案

Google OAuth 2.0 Service Account Impersonate Personal Account

如果想多瞭解一下OAuth2.0的流程動作,可以到OAuth 2.0 Playground來進行實驗喔!

C# OAuth2.0 No3rdParty nonDLL Flow Walkthrough