Laravel 8 - 快速整合 Jetstream + Socialite

本文為 Laravel 7 — Socialite in Action ( Social Media Login Integration with Facebook, Twitter, LinkedIn, Google) 之更新簡化版本。省略大部分說明只提供步驟紀錄。詳細說明請參考原文。

Create Project

1
$ laravel new demo

建立專案之後,請建立資料庫和更新 .env。下面以 Postgre SQL 為例

1
$ createdb demo

.evn 的部分如下:

1
2
3
4
5
6
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=demo
DB_USERNAME=root
DB_PASSWORD=

安裝 Jetstream

1
2
3
4
5
$ composer require laravel/jetstream
$ php artisan jetstream:install inertia --teams
$ npm install && npm run dev
# (opt) For customize template you should publish these views
$ php artisan vendor:publish --tag=jetstream-views

安裝 Socialite

1
2
3
$ composer require laravel/socialite
# (opt) If you meet memory limit error, place `COMPOSER_MEMORY_LIMIT=-1` before command
# $ COMPOSER_MEMORY_LIMIT=-1 composer require laravel/socialite

修改 database schema

詳細原因請參考 Laravel 7 — Socialite in Action ( Social Media Login Integration with Facebook, Twitter, LinkedIn, Google)

1
2
$ composer require doctrine/dbal
$ php artisan make:migration edit_columns_in_users_table

在新增的 migration 檔案中調整如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['email']);
$table->string('password')->nullable()->change();
$table->json('social')->nullable();
$table->softDeletes();
$table->unique(['email', 'deleted_at']);
});
}

public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['email', 'deleted_at']);
$table->dropSoftDeletes();
$table->dropColumn(['social']);
$table->string('password')->change();
$table->string('email')->unique()->change();
});
}

然後執行

1
$ php artisan migrate

Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use Illuminate\Database\Eloquent\SoftDeletes;
// ...
// If you want to support verify you can add implements
class User extends Authenticatable
{
use Notifiable;
use SoftDeletes;

// ...

/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
'social' => 'array',
];

// (opt) Most of case you should keep email in lowercase
public function setEmailAttribute($value)
{
$this->attributes['email'] = strtolower($value);
}
}

Fortify

有兩個 Fortify 相關的檔案須修正,原因是我們現在支援 SoftDeletes,注意 email 的規則 unique 須置換為 unique:users,email,NULL,id,deleted_at,NULL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// app/Actions/Fortify/CreateNewUser.php
<?php
// ...
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
// ...
public function create(array $input)
{
Validator::make($input, [
// ...
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,NULL,id,deleted_at,NULL'],
// ...
])->validate();

return DB::transaction(function () use ($input) {
return tap(User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]), function (User $user) {
$this->createTeam($user);
});
});
}
// ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/Actions/Fortify/UpdateUserProfileInformaiton.php
<?php

// ...

class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
// ...
public function update($user, array $input)
{
Validator::make($input, [
// ...
'email' => ['required', 'email', 'max:255', 'unique:users,email,NULL,id,deleted_at,NULL'],
// ...
])->validateWithBag('updateProfileInformation');

// ...
}

// ...
}

取得平台憑證 Client ID 和 Secret

將您需要的資訊補在 .env,下面只是局部平台的範例

1
2
3
4
5
6
7
8
9
10
11
12
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_CALLBACK_URL=/login/facebook/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=/login/google/callback
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_CALLBACK_URL=/login/linkedin/callback
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
TWITTER_CALLBACK_URL=/login/twitter/callback

Services 設定

config/services.php 加入設定,您可能注意到 scopes 的部分,但在官方文件沒有關於這段。的確這是額外的設定,我覺得將它們放在一起比較合適您也可以放到 .env

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'facebook' => [
'client_id' => env('FACEBOOK_CLIENT_ID'),
'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
'redirect' => env('FACEBOOK_CALLBACK_URL'),
'scopes' => ['email', 'public_profile'],
],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_CALLBACK_URL'),
'scopes' => [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'openid',
],
],
'linkedin' => [
'client_id' => env('LINKEDIN_CLIENT_ID'),
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
'redirect' => env('LINKEDIN_CALLBACK_URL'),
'scopes' => ['r_emailaddress', 'r_liteprofile'],
],
'twitter' => [
'client_id' => env('TWITTER_CLIENT_ID'),
'client_secret' => env('TWITTER_CLIENT_SECRET'),
'redirect' => env('TWITTER_CALLBACK_URL'),
'scopes' => [],
],

Auth Controller

這是本文最重要的段落,也可能是您一直在尋找的部分。我們將建立一個 Controller 來處理 OAuth 回呼的部分

1
$ php artisan make:controller Auth/LoginController

檔案建立之後,下面是完整的 app/Http/Controllers/Auth/LoginController.php 程式碼,雖然有點長,但方便您直接複製貼上並完整理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
use Socialite;

use App\Models\User;
use App\Models\Team;


class LoginController extends Controller
{
protected $redirectTo = RouteServiceProvider::HOME;

/**
* Redirect to authentication page based on $provider.
*
* @param string $provider
* @return \Illuminate\Http\Response
*/
public function redirectToProvider(string $provider)
{
try {
$scopes = config("services.$provider.scopes") ?? [];
if (count($scopes) === 0) {
return Socialite::driver($provider)->redirect();
} else {
return Socialite::driver($provider)->scopes($scopes)->redirect();
}
} catch (\Exception $e) {
abort(404);
}
}

/**
* Obtain the user information from $provider
*
* @param string $provider
* @return \Illuminate\Http\Response
*/
public function handleProviderCallback(string $provider)
{
try {
$data = Socialite::driver($provider)->user();

return $this->handleSocialUser($provider, $data);
} catch (\Exception $e) {
return redirect('login')->withErrors(['authentication_deny' => 'Login with '.ucfirst($provider).' failed. Please try again.']);
}
}

/**
* Handles the user's information and creates/updates
* the record accordingly.
*
* @param string $provider
* @param object $data
* @return \Illuminate\Http\Response
*/
public function handleSocialUser(string $provider, object $data)
{
$user = User::where([
"social->{$provider}->id" => $data->id,
])->first();

if (!$user) {
$user = User::where([
'email' => $data->email,
])->first();
}

if (!$user) {
return $this->createUserWithSocialData($provider, $data);
}

$social = $user->social;
$social[$provider] = [
'id' => $data->id,
'token' => $data->token
];
$user->social = $social;
$user->save();

return $this->socialLogin($user);
}

/**
* Create user
*
* @param string $provider
* @param object $data
* @return \Illuminate\Http\Response
*/
public function createUserWithSocialData(string $provider, object $data)
{
try {
$user = new User;
$user->email = $data->email;
$user->name = $data->name;
$user->social = [
$provider => [
'id' => $data->id,
'token' => $data->token,
],
];
// markEmailAsVerified() contains save() behavior
$user->markEmailAsVerified();
$team = Team::forceCreate([
'user_id' => $user->id,
'name' => $user->name."'s Team",
'personal_team' => true,
]);
$user->current_team_id = $team->id;
$user->save();

return $this->socialLogin($user);
} catch (Exception $e) {
return redirect('login')->withErrors(['authentication_deny' => 'Login with '.ucfirst($provider).' failed. Please try again.']);
}
}

/**
* Log the user in
*
* @param User $user
* @return \Illuminate\Http\Response
*/
public function socialLogin(User $user)
{
auth()->loginUsingId($user->id);

return redirect($this->redirectTo);
}
}

Routes

routes/web.php,注意 Laravel 8 的路由設定語法有些改變。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Auth\LoginController;

// ...

Route::middleware(['auth:sanctum', 'verified'])->get('/dashboard', function () {
return Inertia\Inertia::render('Dashboard');
})->name('dashboard');

Route::get('/login/{provider}', [LoginController::class, 'redirectToProvider'])
->name('social.login');
Route::get('/login/{provider}/callback', [LoginController::class, 'handleProviderCallback'])
->name('social.callback');

Views

最後在 resources/views/auth/ 補上登入的按鈕則完成。使用 php artisan serve 測試看看吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@php
$providers = [
'google' => [
'bgColor' => '#ec462f',
'icon' => 'fab fa-google',
],
'facebook' => [
'bgColor' => '#1877f2',
'icon' => 'fab fa-facebook-f',
],
'linkedin' => [
'bgColor' => '#2969b1',
'icon' => 'fab fa-linkedin-in',
],
'twitter' => [
'bgColor' => '#41aaf1',
'icon' => 'fab fa-twitter',
// ],
];
@endphp

@foreach($providers as $provider => $params)
<a
class="block py-3 px-4 mb-5/2 rounded-sm text-white text-center font-bold hover:no-underline hover:opacity-75"
href="{{ route('social.login', ['provider' => $provider]) }}"
style="background-color: {{ $params['bgColor'] }}; min-height: 48px;"
>
<i class="tw-float-left tw-inline-block tw-h-5 {{ $params['icon'] }}"></i>
Login with {{ ucwords($provider) }}
</a>
@endforeach
分享到