由於前陣子客戶那邊的IMAP服務突然不會運作了,查了一下問題竟然是登入失敗?WTF,明明什麼都沒有動(那個IMAP服務大概有兩三年沒有維護了)突然間就無法登入加上客戶急的跳腳,緊急之下就開始爬文後來才知道微軟把基本驗證(就是在Client端直接輸入帳號密碼的方式)給關掉了【點我前往參考
上面的鏈接是說直接輸入帳號密碼的方式不安全,而微軟要我們直接使用OAuth驗證的方式,OK又是一個新的挑戰讓我們開始Delphi - OAuth的世界吧!

💡 這篇的範例我們會使用Azure AD的OAuth應用程式授權方式來實作,如果不知道什麼是Azure AD以及他的相關設定可以參考以下兩篇文章 



元件準備

首先我們需要準備這幾個元件

  • TsgcHTTP_OAuth2_Client
  • TIdSSLIOHandlerSocketOpenSSL
  • TIdIMAP4
除了TsgcHTTP_OAuth2_Client之外其它兩個大家應該都不陌生才對,另外再準備兩個TButton和一個TMemo,畫面奉上
💡 我們使用到的元件有ESEGECE這家公司推出的OAuth_Client Component【點我前往購買】P.S:絕非業配!主要是公司有買這個元件剛好也有OAuth2的功能可以用就拿來用了XD
在Use Authorization Code Flow - Button的OnClick事件啟動TsgcHTTP_OAuth2_Client元件
procedure TForm1.btn_Authorization_Code_FlowClick(Sender: TObject);
begin
  DoLog('Start Authorization Code Flow');
  OAuth2_Authorization_Code.Start;
end;


在Get IMAP - Button的OnClick事件實作IMAP的連線以及取得指定MailBox裡面的的Email數量

try
    try
      if TIdSASLXOAuth(xOAuthSASL.SASL).Token <> '' then begin
        if not TIdSASLXOAuth(xOAuthSASL.SASL).IsTokenExpired then begin
          DoLog('Start Connect Outlook');
          IdIMAP4.Connect;
          DoLog('Connected Outlook');
          IdIMAP4.SelectMailBox('INBOX');
          DoLog('Your Outlook TotalMsgs: ' + IdIMAP4.MailBox.TotalMsgs.ToString);
          IdIMAP4.Disconnect;
          DoLog('Disconnected Outlook');
        end else begin
          DoLog('Access Token is expired!!');
        end;
      end else begin
        DoLog('Access Token is empty!!');
      end;
    except
      on E: Exception do begin
        DoLog('IMAP Exception: ' + E.ToString);
      end;
    end;
  finally
    if TIdSASLXOAuth(xOAuthSASL.SASL).IsTokenExpired then TIdSASLXOAuth(xOAuthSASL.SASL).Token := '';
  end;



實現TIdIMAP4的SASL XOAuth驗證機制功能

客戶的收信服務本身是使用TIdIMAP4這個元件為了讓修改幅度降到最低因此選擇繼續沿用TIdIMAP4,但是由於TIdIMAP4本身沒有連OAuth的功能因此要自己另外實作,實作前IdIMAP4需要先設定幾個屬性

  • AuthType:這裡需要調整為iatSASL,預設是iatUserPass但由於大多的IMAP都開始不支持Auth Login的方式了因此未來應該都會直接使用iatSASL的方式
  • Host:這裡就直接填入Outlook的IMAP Server Host
  • Port:Outlook的IMAP Server Port
  • UseTLS:這裡直接使用utUseImplicitTLS(隱式TLS)

接著要實作SASL的XOAuth驗證方式,這裡我就直接參考別人寫好的TIdSASLXOAuth【點我前往Github參考範例

unit IdSASLXOAUTH;

interface

uses
  System.SysUtils,
  System.DateUtils,
  Classes,
  IdSASL
  ;

type
  TIdSASLXOAuth = class(TIdSASL)
  private
    FToken: string;
    FUser: string;
    FExpireTime: string;
    FGetTokenDateTime: TDateTime;
    procedure GetToken(const Value: string);
  public
    property Token: string read FToken write GetToken;
    property User: string read FUser write FUser;
    property ExpireTime: string read FExpireTime write FExpireTime;
    class function ServiceName: TIdSASLServiceName; override;
    constructor Create(AOwner: TComponent);
    destructor Destroy; override;
    function TryStartAuthenticate(const AHost, AProtocolName : String; var VInitialResponse: String): Boolean; override;
    function ContinueAuthenticate(const ALastResponse, AHost, AProtocolName : string): string; override;
    function StartAuthenticate(const AChallenge, AHost, AProtocolName: string): string; override;
    { For cleaning up after Authentication }
    procedure FinishAuthenticate; override;
    function IsTokenExpired: Boolean;
  end;

implementation

{ TIdSASLXOAuth }

class function TIdSASLXOAuth.ServiceName: TIdSASLServiceName;
begin
  Result := 'XOAUTH2';
end;

constructor TIdSASLXOAuth.Create(AOwner: TComponent);
begin
  inherited;
  FExpireTime := '3599';
end;

destructor TIdSASLXOAuth.Destroy;
begin
  inherited;
end;

function TIdSASLXOAuth.TryStartAuthenticate(const AHost, AProtocolName: String; var VInitialResponse: String): Boolean;
begin
  VInitialResponse := 'user=' + FUser + Chr($01) + 'auth=Bearer ' + FToken + Chr($01) + Chr($01);
  Result := True;
end;

function TIdSASLXOAuth.StartAuthenticate(const AChallenge, AHost, AProtocolName: string): string;
begin
  Result := 'user=' + FUser + Chr($01) + 'auth=Bearer ' + FToken + Chr($01) + Chr($01);
end;

function TIdSASLXOAuth.ContinueAuthenticate(const ALastResponse, AHost, AProtocolName: string): string;
begin
  // Nothing to do
end;

procedure TIdSASLXOAuth.FinishAuthenticate;
begin
  // Nothing to do
end;

procedure TIdSASLXOAuth.GetToken(const Value: string);
begin
  FToken := Value;
  FGetTokenDateTime := now;
end;

function TIdSASLXOAuth.IsTokenExpired: Boolean;
var
  FExpireDateTime: TDateTime;
begin
  FExpireDateTime := IncSecond(FGetTokenDateTime, StrToInt(FExpireTime));
  Result := FExpireDateTime <= Now
end;

end.


最後在FormCreate的事件上建立和添加一下XOAuth的SASL

procedure TForm1.FormCreate(Sender: TObject);
begin
  xOAuthSASL := IdIMAP4.SASLMechanisms.Add;
  xOAuthSASL.SASL := TIdSASLXOAuth.Create(Self);
end;



實作Authorization Code Flow做授權驗證

使用TsgcHTTP_OAuth2_Client前需要細部設定一下OAuth的屬性

  • OAuth2Options:
    • Client ID:用戶識別碼,直接帶入Azure AD的應用程式識別碼
    • Client Secret:用戶識別密碼,由於我們這裡的Redirect URI在Azure AD上是一個Public Client因此這裡不需要填入Client Secret【點我前往參考詳細問題
    • Grant Type:授權方式,我們示範的是Authorization Code Flow因此直接選擇auth2Code
      💡 目前這個元件只支持Authorization Code Flow和Client Credentials Flow
    • Password:無需設定
    • Username:無需設定
  • AuthorizationServerOptions:
    • AuthURL:授權伺服器驗證URL
    • Scope:請求的權限
    • TokenURL:驗證成功後取得Token的URL
  • LocalServerOptions:
    • IP:IP位置
    • Port:連結阜
    • RedirectURL:這個RedirectURL需要添加到Azure AD的應用程式的平台認證否則會無法使用【點我前往參考設定
      💡 這個元件啟動之後會執行一個WebServer是拿來讓你當作Redirect URI,這裡的相關設定都是關於這個WebServer的一些設定


屬性設定完成後接著來設定事件,Authorization Code Flow會有回傳AuthorizeCode和請求AccessToken的動作因此需要加入這兩個事件「OnAfterAccessToken」「OnAfterAuthorizeCode」,另外如果想要知道請求錯誤訊息可以加入「OnErrorAuthorizeCode」「OnErrorAccessToken」

  • OnAfterAuthorizaCode:取得Auth Code的Callback

procedure TForm1.OAuth2_Authorization_CodeAfterAuthorizeCode(Sender: TObject;
  const Code, State, Scope, RawParams: string; var Handled: Boolean);
begin
  DoLog('Code: ' + Code + CRLF +
        'State: ' + State + CRLF +
        'Scope: ' + Scope);
end;

  • OnAfterAccessToken:取得Access Token的Callback
procedure TForm1.OAuth2_Authorization_CodeAfterAccessToken(Sender: TObject;
  const Access_Token, Token_Type, Expires_In, Refresh_Token, Scope,
  RawParams: string; var Handled: Boolean);
begin
  DoLog('AccessToken: ' + Access_Token + CRLF +
        'Token_Type: ' + Token_Type + CRLF +
        'Expires_In: ' + Expires_In + CRLF +
        'Refresh_Token: ' + Refresh_Token + CRLF +
        'Scope: ' + Scope);
  TIdSASLXOAuth(xOAuthSASL.SASL).Token := Access_Token;
  TIdSASLXOAuth(xOAuthSASL.SASL).ExpireTime := Expires_In;
  TIdSASLXOAuth(xOAuthSASL.SASL).User := '你要授權登入的電子郵件名稱@outlook.com';
end;
這裡取得Token之後就把Token和你的電子郵件都帶入剛剛的TIdSASLXOAuth屬性內


執行Authorization Code Flow

啟動程式直接執行就會開始整個Authorization Code Flow的流程,我們只需要做Single Sign On驗證其餘的TsgcHTTP_OAuth2_Client會幫你完成,如果沒有發生問題就可以在畫面上看到Auth Code和AccessToken

💡 127.0.0.1:8080就是我們的Redirect URI,可以看到回傳的Code跟畫面事件回傳的Code是一樣的


最後點擊Get IMAP的按鈕用這組Token取得Mail看看

完成!順利連線也成功取得Mail的數量!




總結

做到這裡是不是覺得哪裡怪怪的呢?給大家思考一下!

再思考一下XDD

沒錯問題就是出在Authorization Code Flow需要有Resource Owner(這裡指的是人)」去做一個授權的動作,我們可以看到微軟給的Token ExpireTime是3600秒,等於一個小時要叫Resource Owner做一次登入和授權的動作(備註1),可是今天我們的IMAP是一個服務是不可能有Resource Owner可以來介入的,因此Authorization Code Flow是不適合我們的服務類型的,因此我們必須重新換一個OAuth的流程才行!

💡 備註1: 由於我們的Redirect URI設定的是公用裝置因此並沒有Refresh Token可以使用,如果我們的Redirect URI是WebAPI就會有RefreshToken,有了Refresh Token也就不用一直登入了,直接Refresh新的Token即可,但是第一次使用還是必須要有Resource Owner介入,因此還是不符合我們IMAP服務要的


程式碼下載:Delphi - OAuth2IMAP