iT邦幫忙

1

Laravel 實作 Facebook OAuth 第三方登入筆記

步驟一:申請facebook開發應用程式,在『 產品 』中新增facebook登入。

https://ithelp.ithome.com.tw/upload/images/20200225/20120024lpqMsTWWAq.png

步驟二:在專案目錄下安裝facebook SDK,並開始撰寫程式

  1. 安裝facebook SDK套件
composer require facebook/graph-sdk
  1. .env加入應用程式id、應用程式密鑰

第三方登入大致流程:

  1. 使用者點下『 facebook登入 』連結,會發request到server端,而server端則會回傳fb登入畫面連結,query string包括client_id, state(類似CSRF token的作用), response_type, sdk版本, redirect_uri( call-back api), scope(要求的使用者資料範圍)等,讓前端顯示。
  2. 登入成功後,client端這時會發送一個GET的請求到server的call-bac這支api(也就是剛剛在query string的redirect_uri),query string包含code(這位使用者成功登入後所拿到的驗證碼)和state,在這個檔案中拿到使用者access token,再發送請求向fb換取使用者meta data。
  3. 後端儲存(第一次登入)或更新使用者資料進DB,告知前端使用者登入成功並回傳後端產生的api_token和使用者資料。

看圖比較快
https://ithelp.ithome.com.tw/upload/images/20210618/20120024TggVuUssfn.png

整個過程的重點就是,讓使用者能以facebook的帳號登入,讓後端能儲存使用者的facebook資料,後端再把自己產生的api_token給前端。
facebook OAuth 官方文件有提供詳細的範例程式碼及說明,因此不在此說明程式碼的部分。
不過要特別注意的是,程式碼中必須在開頭加上seesion_start(),在跳轉頁面時才能將state參數內容傳到下一個網頁,否則會出現CSRF問題。

步驟三:部署程式碼至雲端,並將應用程式改成已上線狀態

應用程式狀態分為已上線與調整中。
應用程式上線前置作業:隱私政策網址(可用github page製作)和在設定中加入有效的 OAuth 重新導向 URI。
在『 調整中 』階段,只提供我們以已授權的開發人員fb帳號登入測試,此階段可以暫時以localhost來作為測試網域;一旦將狀態改成已上線後,就必須使用https開頭的URL。
當使用者成功登入facebook後,會發請求到call-back這裏,也就是這邊要填寫的OAuth 重新導向 URI。這個跳轉的網址其實是由前端帶的,而facebook的開發者頁面之所以要求要填入這串,目的是為了確保前端帶上的redirect_url是否屬於application server這的。每次跳轉,fb都會檢查這個網址有沒有存在於有效的 OAuth 重新導向 URI這個欄位裡。

https://ithelp.ithome.com.tw/upload/images/20200225/20120024t1p0xX7TnA.png

看一下call-back這個method

    //登入成功後,以code換取使用者AccessToken
    public function fbCallback()
    {
        session_start(); //to deal with CSRF

        $fb = new Facebook([
            'app_id' => env('FB_CLIENT_ID'),
            'app_secret' => env('FB_CLIENT_SECRET'),
            'default_graph_version' => 'v3.2',
        ]);

        $helper = $fb->getRedirectLoginHelper();

        try {
            $accessToken = $helper->getAccessToken();
        } catch (FacebookResponseException $e) {
            // When Graph returns an error
            return response()->json('Graph returned an error: ' . $e->getMessage(), 400);
        } catch (FacebookSDKException $e) {
            // When validation fails or other local issues
            return response()->json('Facebook SDK returned an error: ' . $e->getMessage(), 400);
        }

        if (!isset($accessToken)) {
            if ($helper->getError()) {
                return response()->json(
                    "Error: " . $helper->getError() . "\n" .
                    "Error Code: " . $helper->getErrorCode() . "\n" .
                    "Error Reason: " . $helper->getErrorReason() . "\n" .
                    "Error Description: " . $helper->getErrorDescription() . "\n"
                    , 401);
            } else {
                return $this->sendError('Bad request', 400);
            }
        }
        try{
            // 若登入成功,拿accessToken來換使用者資料
            $login = $this->login($accessToken);
            return View::make('layout.loginSuccess')->with('user', $login);

        }catch (Exception $e){
            return view('layout.loginFailed');
        }

    }
    
    //換取使用者資料,將以儲存或更新,並在server端產生新的token(for 應用程式)
     public function login($token)
    {
        date_default_timezone_set('Asia/Taipei');
        $fb = new Facebook([
            'app_id' => env('FB_CLIENT_ID'),
            'app_secret' => env('FB_CLIENT_SECRET'),
            'default_graph_version' => 'v3.2',
        ]);

        $endpoint = env('FBEndpoint');

        $response = $fb->get($endpoint, $token);
        $resource = $response->getGraphUser();
        if (count(User::where('account', $resource['id'])->get()->toArray()) == 0) {
            $create = User::create([
                'name' => $resource['name'],
                'access_token' => $token->getValue(),
                'account' => $resource['id'],
                'password' => 'facebook',
                'api_token' => Str::random(20),
                'image' => $resource['picture']['url'],
                'point' => 0,
                'bad_record' => 0,
            ]);
            return $create;

        } else {
            $user = User::where('account', $resource['id'])->first();
            $user->update([
                'name' => $resource['name'],
                'api_token' => Str::random(20),
                'access_token' => $token->getValue(),
                'image' => $resource['picture']['url'],
            ]);
            return $user;
        }

    }

登入成功的話,就能拿到向fb請求的使用者metadata了。

實作fb Oauth踩到的坑:跨域問題

所謂的跨域問題是當client端向server請求資源時,會先發一個preflighted(預檢請求),先發送 OPTIONS 請求進行確認,瀏覽器會檢查server端和client端的網域是否一致,若不一致則會阻止client端發送接下來的request。這個機制是瀏覽器為了避免使用者受到釣魚網站等攻擊而設計的,但對於開發人員來說,在測試階段會是個很麻煩的問題。
由於這個專案在開發時,只有我(後端)的部分的程式碼有部署到雲端,而和我合作的前端是在本機測試串接第三方登入的,因此我們前後端在不同的網域,被跨域問題搞了很久。由於OAuth的流程是:

  1. 當使用者點下facebook登入時,會發request到server端(call redirect這支api),告訴server使用者要透用facebook登入,此時server會跳轉到facebook登入的畫面讓client端顯示。
 function redirect()
    {
        session_start();
        $fb = new Facebook([
            'app_id' => env('FB_CLIENT_ID'),
            'app_secret' => env('FB_CLIENT_SECRET'),
            'default_graph_version' => 'v3.2',
        ]);
        $helper = $fb->getRedirectLoginHelper();
        $permissions = ['email']; // Optional permissions

        $loginUrl = $helper->getLoginUrl(env('FB_REDIRECT'), $permissions);
        return redirect($loginUrl); //跳轉到facebook登入畫面
    }

2.當使用者輸入完密碼,若登入成功,client端會callcall-back這支api(網址定義在env('FB_REDIRECT')裡),server端透過fb給的參數$code(裡面帶有該位使用者登入成功的資訊),在call-back的這個function中換取該使用者的access token,根據這個access token能換取使用者資料(帳號、姓名、大頭照等),將使用者資料存入資料庫後,再回傳給前端server這邊所存的使用者資料。
跨域問題就出在使用者送出帳號密碼,fb的sdk自動call server端的call-back時,就算server這邊的header有帶處理跨域問題的三個header(allow origin, allow method, allow headers),跳轉到fb的網頁時也還是會遇到跨域問題,因為facebook Oauth的SDK就是不會帶這些header,我們不能也不應該(安全性考量)幫他加上去。

Solution:

後來問了比較資深的前輩,得到的答案是,若是正式上線的情況,前後端的code應該會被部署到同個網域,因此理應不會有跨域問題,後端header帶allow origin,尤其value是" * "時是很危險的,表示無論哪個網域都能向server請求資源。

但是在測試階段,前後端的程式碼還沒部署在一起時怎麼辦?

由於前端不能把facebook的登入畫面直接嵌在自己刻的畫面裡,因此前端JavaScript要以window.postMessage的方式,開啟另一個視窗讓使用者登入facebook帳號,登入成功/失敗後,後端用postMessage的方式把使用者的metadata帶在header給前端拿。

postMessage是一個能在不同網頁之間傳送文字資料的方式,有點類似前端開一個母視窗,使用者登入成功後再開一個對應的子視窗,並且把資料帶在這個視窗的header中。

而且無論登入成功或失敗,顯示登入結果的畫面也要寫在後端,並把要給前端的資訊帶在header,才不會讓使用者看到,這樣才能避免call-back跑完後,跳轉回寫前端的網佔不會又碰到跨域問題。
⬇登入成功的畫面(使用者的meta data在header中),會是空白的是因為這個畫面必須由後端顯示,但又不能把使用者meta data直接暴露在畫面上,因此資料帶在header給前端拿,javascript以postWindow,對應後端postMessage的方式拿到資料:
https://ithelp.ithome.com.tw/upload/images/20200331/20120024QmbKJmTB36.png

前端程式碼

 const getFBLoginData = msg => {
    if (msg.origin === serverDomain) {
      if (msg.data.status === "success") {
        setToken(msg.data);
        localStorage.setItem("user_token", msg.data.api_token);
      } else {
        console.log("FB login error");
      }
    }
  };
  const getFB = () => {
    window.addEventListener("message", getFBLoginData);
    window.open(
      `後端redirect uri`,
      "_blank",
      "menubar=no,toolbar=no"
    );
  };

後端登入成功畫面的程式碼,登入成功的話,message放的就是使用者的meta data;登入失敗的話,則是回傳登入失敗的message讓前端知道。

<!DOCTYPE html>
<html>
<head>
    <title>Facebook Login Success</title>
    <meta charset="UTF-8">
</head>
<body>
<script>
    var message = new Object();

    message.status = 'success';
    message.id = '{{$user['id']}}'
    message.name = '{{$user['name']}}'
    message.account = '{{$user['account']}}'
    message.api_token = '{{$user['api_token']}}'
    message.image = '{{$user['image']}}'
    message.email = '{{$user['email']}}'
    message.point = '{{$user['point']}}'
    message.phone = '{{$user['phone']}}'

    window.opener.postMessage(message, '*');
    window.close();

</script>
</body>

</html>



圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言