不透過第三方元件,完整實作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("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了。

特別注意下列事項

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

※ 相關參考

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

C# OAuth2.0 No3rdParty nonDLL Flow Walkthrough