2024年12月3日 星期二

在應用程式中整合 Google 帳號登入功能

 

從登入到社群帳號登入

從 Delphi 開始出現的1990年代中期開始,各種應用程式就有不同的方式提供使用者登入的功能,包含自己建立的帳號系統、Email與密碼、Windows AD等等。要透過當年不斷更迭的資料庫元件,例如 BDE,ADO,對Delphi的開發人員都算不上問題。

到了2000年初期,各種Email信箱的登入與驗證多了起來,2007-2017之間,Facebook, IG, LINE的社群登入,以OAuth方式的協定,讓Delphi中內建了支援TOAuth的 Client元件,需要 SSL,TLS等等安全驗證元件,都需要不同平台的OpenSSL Library,在那個 Delphi 開始復甦,卻還讓大家難以掌握的年代裡,就比較頭痛。

但Embarcadero從2017年開始,就開始提供跨平台可以直接使用的TNetHTTPClient以及TRestClient元件,讓開發人員可以減少一些因為Indy元件不容易處理OpenSSL函式庫的不便,但仍然有很多開發上的煩燥之處。(請別誤以為筆者這是在說Indy不行,畢竟筆者也是Indy團隊成員之一,TIdDNSServer是我在2004年完成的,Indy仍然是很棒的元件喔) 

時序到了2018之後,登入帳號這件事情,在Google跟Microsoft、Apple的努力之下,從手機、平板、電腦瀏覽器登入社群軟體、GMail,微軟帳號、Apple的Game Center不再每次都需要輸入帳號密碼,從輸入帳密,變成了2層式驗證(2FA),或是多層式身分驗證(MFA),註冊與登入這件事情越來越麻煩,但操作上越來越簡單,只有第一次需要點一下想要用來登入的帳號,輸入一次密碼,並在手機或平板上進行一次驗證。第二次之後的登入,都只需要點擊帳號就可以完成這個程序了。

用戶端的簡化,程式端的複雜

用戶端的操作過程可以越來越簡單,但卻苦了開發人員。尤其這幾年幾乎所有的流程跟API都Web化,對於應用程式,尤其是Windows端的應用程式來說,要整合Web API,再從Web抓到所需的資料作為後續在應用程式中使用,真的很複雜。

在Delphi的全球社群,以及Embarcadero的開發團隊中,對於這幾年在網路中的各種新式身分驗證標準,不斷的更新,以對於RFC 7520 JOSE(JSON Object Signing and Encryption)為例,官方在Delphi 12.2的系列研討會裡面,就介紹了Delphi的JOSE函式庫,在GitHub上面,也有越來越多的函式庫,例如FB4D (FireBase For Delphi),都是Embarcadero全球各地的前輩跟MVP們努力的成果。

站在巨人的肩膀上,我們也能有巨人般的步伐。

FB4D提供了很完整的API,讓我們可以在Delphi的程式中,不論您使用的是VCL或者FireMonkey,都可以快速的使用FireBase系列的API,讓使用者用自己的Google帳號註冊或登入我們開發的應用程式了。

可惜的是,這系列的API當中,沒有提供用戶直接以瀏覽器選擇自己帳號登入的選項,所以筆者把這一段簡單的補足給想要這麼做的開發人員。

讓用戶直接透過瀏覽器,以Google帳號登入吧

不多說,直接來看圖。

這個畫面,相信大家一定不陌生,這是在各種網頁應用程式或者手機遊戲當中,常見的『以Google帳號』登入選項被點擊之後,常會出現的畫面,此時,只需要輸入用戶的Google帳號,再輸入密碼之後,如果有設定要使用2FA的話,就會出現以下的畫面。

接著,如果使用者完成了2FA或者MFA的步驟,我們的程式就會接收到登入完成的通知,就可以繼續進入程式功能了,在筆者這次的範例中,只會從Google帳號端取得用戶的簡單資訊,將它顯示在畫面上:

如果這是註冊程序,就能把抓到的這些資訊寫入註冊的資料表中,如果已完成註冊,則可以透過抓到的這些資訊,識別使用者的身分,並在應用程式中打開對應的授權功能了。
 

原本要做到2FA或者MFA,對中小型的系統服務提供者來說,都是很麻煩的事情。但透過了Firebase這個工具的幫助,可以幫我們把Google帳號、微軟帳號、Facebook、IG等常用的社群帳號一次整合完成,而且介面也是用戶們熟悉的介面,不用我們另外費心,身為懶惰的開發人員,這個懶怎麼可以不偷?

一步一步完成整合

要完成這功能,首先得從Firebase開始。

請先進入到Firebase控制台,建立您的Firebase專案。如果您已經建立過Firebase專案,也可以從Firebase控制台直接點擊該專案。


在上圖的頁面中,左側有個Authentication,就是用來啟用Firebase身分驗證服務的。請點擊該選項,進入Authentication設定頁,如下圖所示:


這個頁面的設定項目中,請選擇右邊主要內容頁籤的『登入方式』,點選『新增供應商』,把Google新增進去,點選新增供應商的時候,會有『電子郵件/密碼』、Google、Microsoft、Apple GameCenter、Facebook等多個項目可以選擇,本文要介紹的是Googl登入的功能,所以請直接點選Google進入下個頁面。

Google登入的供應商設定頁面中,有兩個可以展開的部分,第一部分『將外部專案的用戶端ID新增至許可清單(選用)』可以完全不用理會,因為本文的設定,是要在Windows應用程式的環境中使用這個登入方法,而第一部分的設定是對於手機App的設定,所以我們可以先不理會它,哪天要製作App的時候再來面對~

 

設定Redirect URL(重新導向網址)

前述的設定都完成後,得要到GCP的API控制台設定Redirect URL,這個網址會讓用戶用來登入帳號的瀏覽器在完成登入程序之後,進行重新導向,一般在網頁應用程式中,會把這個網址設定為該網頁應用程式中的另一個專門用來驗證登入結果的網址。

而在手機應用程式中,可以在App中自訂一個只有該App專用的URL,例如myapp://loginprocedureOK/,在登入程序完成後,瀏覽器重新導向這個網址之後,手機應用程式就可以接收到這個訊息,進行登入成功或登入失敗的後續處理。

那在Windows應用程式中怎麼辦?簡單,我們自己在Windows應用程式裡面放一個WebServer不就好了?我們用的是Delphi,我們有TIdHTTPServer,並不是要做完整的WebServer,只要接收一個網址,這就只是一片小蛋糕而已。

在上面的這個設定中,筆者把登入程序完成後的重新導向網址設定為http://localhost:5877/passAuth,這個網址可以隨您的喜歡設定,只要是localhost,使用1024以上的Port,後面的路徑可以隨我們自訂,接下來的篇幅中以Delphi搭配TIdHTTPServer把這個Request承接下來即可。

Delphi程式開始

在本文當中,筆者使用的是Delphi 12.2 (Athen),請新增一個專案,看你自己喜歡VCL或者FMX都可以,本文當中的做法是用瀏覽器,要用VCL或者FMX都行,因為筆者都試過了,哪種框架都可以運作。

在這個專案中,筆者只簡單的在Form裡面放了三個TEdit,分別用來顯示登入後回傳的Name、Issuer(回傳Token的組織名稱)、使用者的Id(Sub Id),再多加一個TImage,用來顯示用戶設定在Google帳號中的圖片。

在上圖的Form當中,當然還有一些TLabel,用來作為提示或者文字,分別是Edit前的描述文字,以及右方用來顯示登入成功與否的文字。

在Form裡面,除了視覺化元件之外,還有四個隱形元件,分別是TRestClient,TRestRequest,TIdHTTPServer以及TTimer。TRestClient跟TRestRequest,是當成HTTP Client元件使用的,細節可以參考筆者幾年前的另一篇文章,本文中不再贅述。

之所以用TRestClient跟TRestRequest作為HTTP Client元件,在本文開頭的第三段裡,筆者已經提到過,只是當時筆者提到的是TNetHTTPClient,而TRestClient跟TRestRequet都是同一系列的元件。

除了這個Form之外,我們還需要另一個用來讓用戶們登入的Form,裡面只需要放一個WebBrowser元件,如下圖所示:

上圖非靜止畫面~這個畫面中,筆者放了一個TWebBrowser,並將它設定為AlignClient,所以看起來整個Form就是空的。

程式分成兩個Form,一個是FormMain,一個是FormLogin。在FormMain當中,Form建立時,先把FLogin指派為false,當FormMain被Activate的時候,檢查Login狀態,如果Login並非true,就呼叫FormLogin.tryLogin,把FormLogin用ShowModal顯示在FormMain的中央,讓使用者登入,我們看一下這幾段程式碼:

procedure TFormMain.FormActivate(Sender: TObject);
begin
   if not self.login then begin
      self.IdHTTPServer1.Active := True;
      UnitLogin.FormLogin.tryLogin;
   end
   else if self.IdHTTPServer1.Active then begin
      self.IdHTTPServer1.Active := False;
   end;
end;
procedure TFormLogin.tryLogin;
var
   targetURL: String;
begin
   targetURL := 'https://accounts.google.com/o/oauth2/auth?
                client_id=' +myClientId + '&redirect_uri=' + 
                myRedirectURI +
                '&response_type=code&scope=profile email';

   self.WebBrowser1.Navigate(targetURL);
   self.ShowModal;
end;

在上面這段程式碼裡面,可以看到從FormMain要呼叫FormLogin.tryLogin之前,先把IdHTTPServer1.Active改為True,啟動了本機上的WebServer,這個元件的設定只把DefaultPort改為5877,其餘都是預設值。

在FormLogin.tryLogin裡面,則只是讓WebBrowser1瀏覽Google的網址,當中的myClientId,就是前文中提到登入方式的Google畫面中『Web SDK設定->網路用戶端ID』,請直接從該畫面中複製該字串下來即可。

當中的myRedirectURL,就是在前面提到的GCP API控制台裡面設定的重新導向URL,筆者把這個字串設定為http://localhost:5877/passAuth,待會登入完成了,無論成功或失敗,瀏覽器元件都會自動重新導向到本機的WebServer這個網址,正好可以從IdHTTPServer1接收到重新導向的內容。

執行的畫面如下:

如果用戶是第一次使用這個Windows應用程式,就會依序顯示上面這兩個畫面,如果用戶已經使用過這個應用程式,而且已經授權登入,就會直接完成登入過程,進入主畫面。

對於用戶來說,使用過程就很順暢。

接著我們來看一下重新導向的程式碼接收到什麼資訊:

procedure TFormMain.IdHTTPServer1CommandGet(AContext:
    TIdContext; ARequestInfo: TIdHTTPRequestInfo;
    AResponseInfo: TIdHTTPResponseInfo);
var
   AuthCode: string;
begin
   if ARequestInfo.Document = '/passAuth' then begin
      AuthCode := ARequestInfo.Params.Values['code'];

      if AuthCode <> '' then begin
         AResponseInfo.ContentText :=
             '<html><body>Login successful! You can close this
              window.</body></html>';
         AResponseInfo.ContentType := 'text/html';

         RetrieveUserToken(AuthCode);
      end
      else begin
         AResponseInfo.ContentText :=
             '<html><body>Error: No authorization code
              found.</body></html>';
         AResponseInfo.ContentType := 'text/html';
      end;
   end
   else begin
      AResponseInfo.ResponseNo := 404;
      AResponseInfo.ContentText := 'Not Found';
   end;
end;
procedure TFormMain.RetrieveUserToken(const AuthCode: string);
var
   tmpJSONObj: TJSONObject;
   AccessToken: String;
   theUser: TFirebaseUser;
begin
   self.RESTRequest1.Method := TRESTRequestMethod.rmPOST;
   self.RESTClient1.BaseURL := 'https://oauth2.googleapis.com/
        token';
   self.RESTClient1.ContentType := 'application/x-www-form-
        urlencoded';

   self.RESTRequest1.AddParameter('code', AuthCode,
        pkGETorPOST);
   self.RESTRequest1.AddParameter('client_id', myClientId,
        pkGETorPOST);
   self.RESTRequest1.AddParameter('client_secret',
        myClientSecret, pkGETorPOST);
   self.RESTRequest1.AddParameter('redirect_uri',
        myRedirectURI, pkGETorPOST);
   self.RESTRequest1.AddParameter('grant_type',
        'authorization_code', pkGETorPOST);

   self.RESTRequest1.Execute;
   if Assigned(self.RESTRequest1.Response.JSONValue) then
   begin
      tmpJSONObj := self.RESTRequest1.Response.JSONValue as
                    TJSONObject;
      AccessToken := tmpJSONObj.GetValue<string>
                    ('access_token', '');
      currentUserIdToken := tmpJSONObj.GetValue<string>
                    ('id_token', '');

      self.FJWT := TJWTv2.Create(currentUserIdToken);

      self.EditUserName.Text := self.FJWT.Claims.userName;
      self.EditIssuer.Text := self.FJWT.Claims.Issuer;
      self.EditSub.Text := self.FJWT.Claims.Subject;
      self.userPicURL := self.FJWT.Claims.userPhotoURL;

      self.Memo1.Lines.Add(currentUserIdToken);

      self.login := True;
      self.TextLoginStatus.Text := 'Login OK!';
      self.TimerRefresh.Enabled := True;
   end
   else begin
      self.Memo1.Lines.Add('Login not work, token not
           retrieved.');
   end;
end;

在這段程式碼當中,筆者自己寫了一個元件:TJWTv2,把回傳的Token解析為TJOSE當中的TJWT類別格式,但TJWT當中只有簡單的幾個欄位,在Google回傳的JWT當中,還有不少延伸的欄位,所以筆者擴充了這個Class,在IdHTTPServer1.OnCommandGet事件被觸發的時候,我們把Google回傳的第一階Access Token傳到oauth2 API取得JWT,便把取得的JWT解析出來。

在TJWTv2當中,筆者擴充了Claims的欄位,新增userName, userPhotoURL, userEmail, userEmailVerified, userGivenName, userFamilyName等幾個欄位。也就順便把userName, Issuer, Subject, 以及userPhotURL指派給FormMain裡面的幾個對應欄位作為顯示。

在FormMain.userPicURL的setter當中,筆者直接封裝了下載圖片回來塞進TImage的程式邏輯,在此也分享給大家:

procedure TFormMain.SetuserPicURL(const Value: String);
var
   webClient: TNetHTTPClient;
   ms: TMemoryStream;
   resp: IHTTPResponse;
begin
   FuserPicURL := Value;

   if (Pos('http://', Value) = 1) or
      (Pos('https://', Value) = 1) then begin
      webClient := TNetHTTPClient.Create(nil);
      try
         ms := TMemoryStream.Create;
         resp := webClient.Get(Value, ms);
         if (resp.StatusCode = 200) and (ms.Size > 0) then
         begin
            ms.Position := 0;
            self.Image1.Bitmap.LoadFromStream(ms);
         end;
      finally
         webClient.Free;
         ms.Free;
      end;
   end;
end;

封裝是Delphi當中很常見的做法,但考量到可能有些讀者對於OOP還不熟悉,所以順便提一下,希望對大家有幫助。

執行後,畫面就如下圖:

在Google登入的回傳資料中,Subject就是使用者的ID,這個ID是跨應用程式的,如果我們有多個FireBase專案,在FireBase用Email跟帳號登入時,會回傳localId,同一用戶在每個FireBase專案的localId會不同,但Subject在不同的FireBase專案中則會是一致的。

希望這篇文章對大家有幫助,說不定過一陣子筆者會把這個專案錄成影片跟大家分享,畢竟看著影片一步步操作跟看文章還是有些不一樣。

以下先分享TJWTv2的原始碼,需要使用的話,請記得需要先下載TJOSE函式庫喔,或者下載FB4D,當中也有TJSOE函式庫。

{******************************************************************************}
{                                                                              }
{  JWTv2, Decode from JWT                                                      }
{  Copyright (c) 2024-2025 Dennies Chang                                       }
{  dennies@ms4.hinet.net, dennies226@gmail.com                                 }
{                                                                              }
{******************************************************************************}
{                                                                              }
{  Licensed under the Apache License, Version 2.0 (the "License");             }
{  you may not use this file except in compliance with the License.            }
{  You may obtain a copy of the License at                                     }
{                                                                              }
{      http://www.apache.org/licenses/LICENSE-2.0                              }
{                                                                              }
{  Unless required by applicable law or agreed to in writing, software         }
{  distributed under the License is distributed on an "AS IS" BASIS,           }
{  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.    }
{  See the License for the specific language governing permissions and         }
{  limitations under the License.                                              }
{                                                                              }
{******************************************************************************}
unit UnitJWTv2;

interface

uses System.SysUtils,
   System.StrUtils,
   System.DateUtils,
   System.Rtti,
   System.JSON,
   System.Generics.Collections,

   JOSE.Types.JSON,
   JOSE.Types.Bytes,
   JOSE.Core.Base,
   JOSE.Core.JWA,
   JOSE.Core.JWK,
   JOSE.Core.JWT,
   IdGlobal, IdCoderMIME, System.NetEncoding;

type
   TJWTClaimsExtend = class(TJWTClaims)
   private
      FuserEmail: String;
      FuserEmailVerified: Boolean;
      FuserLastName: String;
      FatHash: String;
      FAZP: String;
      FuserPhotoURL: String;
      FuserGivenName: String;
      FuserName: String;
      procedure SetuserEmail(const Value: String);
      procedure SetuserEmailVerified(const Value: Boolean);
      procedure SetatHash(const Value: String);
      procedure SetAZP(const Value: String);
      procedure SetuserGivenName(const Value: String);
      procedure SetuserLastName(const Value: String);
      procedure SetuserName(const Value: String);
      procedure SetuserPhotoURL(const Value: String);
   public
      property userEmail: String read FuserEmail write SetuserEmail;
      property userEmailVerified: Boolean read FuserEmailVerified
          write SetuserEmailVerified;
      property userName: String read FuserName write SetuserName;
      property userGivenName: String read FuserGivenName write SetuserGivenName;
      property userFamilyName: String read FuserLastName write SetuserLastName;
      property userPhotoURL: String read FuserPhotoURL write SetuserPhotoURL;
      property atHash: String read FatHash write SetatHash;
      property AZP: String read FAZP write SetAZP;
   end;

   TJWTv2 = class(TJWT)
   protected
      FClaims: TJWTClaimsExtend;

      procedure parseJWTString(sourceJWT: String);
   public
      constructor Create(strJWT: String); overload;

      property Claims: TJWTClaimsExtend read FClaims;
   end;

implementation

{ TJWTv2 }

constructor TJWTv2.Create(strJWT: String);
begin
   inherited Create(TJWTHeader, TJWTClaimsExtend);
   self.FClaims := TJWTClaimsExtend.Create;

   self.parseJWTString(strJWT);
end;

procedure TJWTv2.parseJWTString(sourceJWT: String);
var
   srcJWT, srcHeader, srcPayload, tmpStr: String;
   jsonHeader, jsonPayload: TJSONObject;
   bValue: Boolean;
begin
   srcJWT := sourceJWT;

   srcHeader := IdGlobal.fetch(srcJWT, '.');
   srcHeader := System.NetEncoding.TBase64Encoding.Base64String.Decode
       (srcHeader);

   jsonHeader := TJSONValue.ParseJSONValue(srcHeader) as TJSONObject;
   if jsonHeader.TryGetValue<String>('typ', tmpStr) then begin
      self.FHeader.HeaderType := tmpStr;
   end;

   if jsonHeader.TryGetValue<String>('alg', tmpStr) then begin
      self.FHeader.Algorithm := tmpStr;
   end;

   if jsonHeader.TryGetValue<String>('kid', tmpStr) then begin
      self.FHeader.KeyID := tmpStr;
   end;
   jsonHeader.Free;

   srcPayload := IdGlobal.fetch(srcJWT, '.');
   srcPayload := System.NetEncoding.TBase64Encoding.Base64String.Decode
       (srcPayload);
   jsonPayload := TJSONValue.ParseJSONValue(srcPayload) as TJSONObject;
   if jsonPayload.TryGetValue<String>('iss', tmpStr) then begin
      self.FClaims.Issuer := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('aud', tmpStr) then begin
      self.FClaims.Audience := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('sub', tmpStr) then begin
      self.FClaims.Subject := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('exp', tmpStr) then begin
      self.FClaims.Expiration := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('iat', tmpStr) then begin
      self.FClaims.IssuedAt := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('nbf', tmpStr) then begin
      self.FClaims.NotBefore := UnixToDateTime(StrToInt64(tmpStr));
   end;

   if jsonPayload.TryGetValue<String>('jti', tmpStr) then begin
      self.FClaims.JWTId := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('email', tmpStr) then begin
      self.FClaims.userEmail := tmpStr;
   end;

   if jsonPayload.TryGetValue<Boolean>('email_verified', bValue) then begin
      self.FClaims.userEmailVerified := bValue;
   end;

   if jsonPayload.TryGetValue<String>('picture', tmpStr) then begin
      self.FClaims.userPhotoURL := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('azp', tmpStr) then begin
      self.FClaims.azp := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('at_hash', tmpStr) then begin
      self.FClaims.atHash := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('given_name', tmpStr) then begin
      self.FClaims.userGivenName := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('family_name', tmpStr) then begin
      self.FClaims.userFamilyName := tmpStr;
   end;

   if jsonPayload.TryGetValue<String>('name', tmpStr) then begin
      self.FClaims.userName := tmpStr;
   end;
end;

{ TJWTClaimsExtend }

procedure TJWTClaimsExtend.SetatHash(const Value: String);
begin
   FatHash := Value;
end;

procedure TJWTClaimsExtend.SetAZP(const Value: String);
begin
   FAZP := Value;
end;

procedure TJWTClaimsExtend.SetuserEmail(const Value: String);
begin
   FuserEmail := Value;
end;

procedure TJWTClaimsExtend.SetuserEmailVerified(const Value: Boolean);
begin
   FuserEmailVerified := Value;
end;

procedure TJWTClaimsExtend.SetuserGivenName(const Value: String);
begin
   FuserGivenName := Value;
end;

procedure TJWTClaimsExtend.SetuserLastName(const Value: String);
begin
   FuserLastName := Value;
end;

procedure TJWTClaimsExtend.SetuserName(const Value: String);
begin
   FuserName := Value;
end;

procedure TJWTClaimsExtend.SetuserPhotoURL(const Value: String);
begin
   FuserPhotoURL := Value;
end;

end.