不透過第三方元件,完整實作OAuth2.0之存取過程
OAuth2.0在當下已經是各大型網站認證的標準,各大型網站也都會幫各Server端的語言、Client端的環境推出各式各樣的程式庫(Library),舉Google的來說,你只是想要驗證並取得使用者的Email,就可能要用NuGet下載四五個以上的DLL才可以,對於愛乾淨的人來說簡直是刺眼到了極點。因此,這篇文章就是想要透過一步一腳印的方式,親自走完所有OAuth2.0的流程,並且取得使用者的相關資訊。
這裡要先聲明,本篇文章中的程式碼,都是我自己對於OAuth2.0的概念性證明(Proof of Concept),由其是類別中有很多沒有考慮Exception或者是罕見狀況的事,所以不建議用在正式用途上,如果要套用到正式用途的話,一定要再加以大幅修正才是。
OAuth2.0的流程
在這邊先用文字簡列一下OAuth2.0的認證流程:
- 一切以AccessToken為主。先找有沒有AccessToken?
- (a)有的話,是否過期?
- (b)沒有的話,與使用者進行互動式驗證。
- 如果過期的話,就使用RefreshToken來更新AccessToken。
- (a)更新AccessToken成功。
- (b)更新AccessToken失敗,有可能是使用者已經透過介面移除你這隻應用程式。
- 如果沒過期的話,就使用AccessToken來進行當初設定Google API Scopes的相關操作。
- (a)操作成功。XD
- (b)操作失敗,有可能是使用者已經透過介面移除你這隻應用程式。
再次強調,當然還有很多未考慮的狀況,例如有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("Chrome.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();
}
P.S 由於有動用到註冊表,所以編譯完成後請以Administrator的權限起來跑,才不會遇到註冊表讀取權限方面的問題。
*程式運行結果a:第一次運行
利用Administrator權限執行程式,產生下列畫面。
程式碼會自動呼叫Chrome瀏覽器,並進行使用者帳號密碼登入等相關提示。可以注意一下畫面中的紅色方框,就是之前在Google Developer Console輸入的資訊。
通過授權後,就可以取得AccessToken了。
請把這組Token複製起來貼回去Console視窗。
接著Console程式碼把你的授權碼拿過去跟Google要資料,經過Google授權後當然沒問題了。可以看到畫面中的使用者名稱、圖像、電子郵件等資訊都已經拿到了。
註冊檔裡面也已經成功的紀錄相關的Token訊息,這個對日後的更新很重要,如果你搬移到正式環境應該放在資料庫中。
*程式運行結果b:正常運行
通常驗證過後,當下的Token會給一小時的存取時間,當你在這個時間內拿既有的Token去驗證,沒問題的話就會產生下方的結果畫面。
*程式運行結果c:權杖有效期限已經過期
如果再驗證時期已經過了Token的有效期間,那就要用RefreshToken去重新要Token了。
特別注意下列事項
- 只要把GetAutenticationURI真正透過瀏覽器重新送出,哪怕是沒有在介面上點同意/允許,更別提把授權碼貼回來程式中的動作根本沒做。總之做了這個動作後,之前所有的AuthCodeFromBrowser、AccessToken與RefreshToken都沒效了。
- Google已經把Profile跟Email全部跟Google+綁在一起,所以如果你取得的JSON裡面,怎樣就是看不到email欄位資訊,那麼,你一定是沒有把你與你的公開 Google+個人資料建立關聯。最簡單的方式,就是去Google+建立起帳戶關聯,並允許相關的存取權限後,再回來看JSON就會有email帳號了(就算事後再去把Google+個人資料砍掉,依然會看到email)。這些資訊是Google從來不會明明白白的告訴你的。
※ 相關參考
如果想多瞭解一下OAuth2.0的流程動作,可以到OAuth 2.0 Playground來進行實驗喔!