composer require facebook/graph-sdk
第三方登入大致流程:
看圖比較快
整個過程的重點就是,讓使用者能以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這個欄位裡。
看一下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了。
所謂的跨域問題是當client端向server請求資源時,會先發一個preflighted(預檢請求),先發送 OPTIONS 請求進行確認,瀏覽器會檢查server端和client端的網域是否一致,若不一致則會阻止client端發送接下來的request。這個機制是瀏覽器為了避免使用者受到釣魚網站等攻擊而設計的,但對於開發人員來說,在測試階段會是個很麻煩的問題。
由於這個專案在開發時,只有我(後端)的部分的程式碼有部署到雲端,而和我合作的前端是在本機測試串接第三方登入的,因此我們前後端在不同的網域,被跨域問題搞了很久。由於OAuth的流程是:
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,我們不能也不應該(安全性考量)幫他加上去。
後來問了比較資深的前輩,得到的答案是,若是正式上線的情況,前後端的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的方式拿到資料:
前端程式碼
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>