Laravel 7 — Socialite in Action ( Social Media Login Integration with Facebook, Twitter, LinkedIn, Google)
In this post, I’ll show you how to integrate those OAuth of social media step by step via Laravel Socialite. We will create a demo project which is not only a demo but also can use in production.
This post using Laravel Framework 7.15.0
It’s also a note for myself if you want to know how to implement with codebase for production. It’s the right place. Let’s getting start from create a project.
$ laravel new demo
After create the project please create a database and setup .env
for it.
// Create database with command line
$ createdb demo
Add configuration, for example I am using postgresql so my .env
will like as follows
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=demo
DB_USERNAME=root
DB_PASSWORD=
Install dependencies & auth
To keep things simple I will use bootstrap in this project you can change to any library or framework you want.
# Switch to project folder
$ cd demo$ composer require laravel/ui
$ php artisan ui bootstrap
$ npm install && npm run dev$ php artisan ui:auth# Start dev server to check everything works fine
# You should see LOGIN and REGISTER on the top right side in index
$ php artisan serve
Install Socialite
$ composer require laravel/socialite
Migration
In this step we like to modify slight columns for users
table. You definitely can just edit the migration file created by default, but I often forget this kind of things so I like to teach you a way that add migrations to modify table after create a table.
First, let’s install a required dependence to support change our table.
$ composer require doctrine/dbal
Then create our migration.
$ php artisan make:migration edit_columns_in_users_table
To understand why we change table as follows, we should explain what goals we want. The requirements list as follows.
- Support
SoftDeletes
means users can register, delete their account and register again but we want to keep all data which support bySoftDeletes
. Henceemail
can NOT be unique but we still need another unique key to avoid create a duplicate email if they didn't delete their account in the database layer. - Log in by social media means users do NOT have to provide
password
so it should benullable
. - Support OAuth of multiple social media and if the email is the same we should merge some information into the record.
- You can enable
verify
or not. The codebase should support various situation.
To ensure you can fully catch up, I post my default migration of users table as well
// Default migration
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
And it is what we add in new migration.
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();
});
}
In down()
method we place the rollback logic as well. In real production, it worth making sure each step can rollback well. This is most of beginner forgot. After everything is done, now, we can migrate.
$ php artisan migrate
Model
Since we add SoftDeletes
and social
column, we have to edit our app/User.php
model. We add for $casts
and setEmailAttribute
function
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',
];
// BTW: in most of case you should keep email in lowercase
public function setEmailAttribute($value)
{
$this->attributes['email'] = strtolower($value);
}
}
Get key and secret from platform
Before we move forward to the next step, you should register those services you want to provide such as Facebook, Twitter, LinkedIn, etc. Because they have different ways to get the credential, so please google it, create app, and get the key. After you get key and secret, you can set up in .env
, please feel free add services you want. We save key and secret here because they are sensitive data you should not commit into version control and once they changed you can easily change them. The callback URL we pre-define here we will create later in Routing section.
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_CALLBACK_URL=/login/facebook/callbackGOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=/login/google/callbackLINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_CALLBACK_URL=/login/linkedin/callbackTWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
TWITTER_CALLBACK_URL=/login/twitter/callback
Configuration
In config/services.php
we should register our services. You may notice we add scopes
but they don't reveal on official document. Yes it is additationl config option I think it better put them together, in fect you can also put it into .env
but need more process to deal with.
// ....'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' => [],
],// ...
Routing
Remember we set up callback URL in .env
, now, we need two routes, to get more information about this part you can refer to docs. Let’s modify routes/web.php
Auth::routes(); // NOTE: If you want to verify you can add ['verify' => true]Route::get('/login/{provider}', 'Auth\LoginController@redirectToProvider')
->name('social.login');
Route::get('/login/{provider}/callback', 'Auth\LoginController@handleProviderCallback')
->name('social.callback');
Controller
It is missing part in official docs and important section as well. We will start from the register part app/Http/Controllers/Auth/RegisterController.php
. Because we modified table of database so we should also change validation.
protected function validator(array $data)
{
return Validator::make($data, [
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,NULL,id,deleted_at,NULL'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
Next we will extend app/Http/Controllers/Auth/LoginController.php
to support Socialite.
namespace App\Http\Controllers\Auth;// ...
use Socialite;
use App\User;class LoginController extends Controller
{
// ...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);
}
}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.']);
}
}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);
}public function createUserWithSocialData(string $provider, object $data)
{
try {
$user = new User;
$user->name = $data->name;
$user->email = $data->email;
$user->social = [
$provider => [
'id' => $data->id,
'token' => $data->token,
],
];
// Check support verify or not
if ($user instanceof MustVerifyEmail) {
$user->markEmailAsVerified();
}
$user->save();return $this->socialLogin($user);
} catch (Exception $e) {
return redirect('login')->withErrors(['authentication_deny' => 'Login with '.ucfirst($provider).' failed. Please try again.']);
}
}public function socialLogin(User $user)
{
auth()->loginUsingId($user->id);return redirect($this->redirectTo);
}
}
View
Finally, we can add login URL to resources/views/auth/login.blade.php
and complete our implementation.
@foreach(['facebook', 'twitter', 'google', 'linkedin'] as $provider)
<a class="btn btn-link" href="{{ route('social.login', ['provider' => $provider]) }}">Login with {{ ucwords($provider) }}</a>
@endforeach@if ($errors->any())
<div class="alert alert-danger">
@foreach ($errors->all() as $error)
<div>{{ $error }}</div>
@endforeach
</div>
@endif
Further Information
You might have questions such as curious about validation rule unique:users,email,NULL,id,deleted_at,NULL. It like docs mention, after unique, the first column is the table name, the second column is the key you want to unique. The third column is for except which is usually use for update
method and you can put the id to except. The rest of statement is arguments that pass into where
by pair.
Though I said it could be used in production, this implementation still gets few problems that need to take care of. The migration we write $table->unique(['email', 'deleted_at'])
is NOT working in database layer if you use SQL
INSERT INTO "users" (email, deleted_at, ...)
VALUES ("andy@example.com", NULL, ...);
It will insert and pass our constraints. But It’s ok because the framework prevents it and works fine. If you concern this you can google and find a lot of discussions. I will not discuss this here and just let you know it exists.