لاگین لاراول با استفاده از پاسپورت و سوشالایت اپل

اگر که از لاراول بعنوان فریم وورک بکندتون استفاده میکنید و api برنامتون رو در اختیار دیگران قرار میدین ، احتمالا از passport و یا لایبرری های مختلف php jwt برای احراز هویت و ارتباط کلاینت ها با برنامتون استفاده میکنید.

توی این پست من فرض رو بر این میذارم که از passport برای پیاده سازی oauth2 و api دادن توی برنامتون استفاده میکنید. از اونجایی که استفاده از پاسپورت بطور کاااااامل و جامع توی داکیومنت خود لاراول توضیح داده شده ، من بطور مختصر مراحل استفاده از پاسپورت رو میگم تااا برسیم به قسمت اصلی ینی استفاده ی همزمان از passport و laravel socialite که کاربرا بتونن هم بطور مستقیم (ینی با تعریف یوزرنیم و پسوورد بصورت عادی )توی سایت ما لاگین یا رجیستر شن ، هم اینکه بتونن با اکانتشون توی شبکه های اجتماعی و سرویس های مختلف (موردی که اینجا توضیح میدم اکانت اپل هست ) توی سایت ما لاگین یا رجیستر کنن و از اون به بعد به endpoint های ما بصورت احراز هویت شده و با token ی که در اختیارشون قرار داده میشه، وصل بشن.

خببببب اول اول همههه ، میریم که passport رو نصب کنیم :

composer require laravel/passport

بعد از نصب هم دستورای زیر رو میزنیم تا جداول مورد نیازمون ساخته بشه و پر بشه:

php artisan migrate
php artisan passport:install

‍‍‍‍‍و بعد trait های HasApiTokens, HasFactory, Notifiable رو توی مدل User تون use میکنید.

و بعد توی فایل config/auth.php کد زیر رو اضافه میکنید:

'api' => [
        'driver' => 'passport',
        'provider' => 'users',
 ],

خب تا اینجا که همه مراحل با جزییات توی داکیومنت لاراول هست.

بعدشششش، باید در جریان باشیم که ، درحال حاضر لاراول، ۴ نوع grant type از oauth2 رو داره :

  1. Authorization Code
  2. Implicit
  3. Password
  4. Client

که توی این صفحه خود داکیومنت لاراول بهشون اشاره کرده :

https://laravel.com/docs/8.x/passport

(برای جزییات بیشتر از هرکدوم از این grant type ها میتونید به خود سایت https://oauth.net/ مراجعه کنید. به ترتیب هم لینکش رو اینجا میذارم) :

  1. https://oauth.net/2/grant-types/authorization-code/
  2. https://oauth.net/2/grant-types/implicit/
  3. https://oauth.net/2/grant-types/password/

4. https://oauth.net/2/grant-types/client-credentials/

که حالااا برای احراز هویت عادی با یوزرنیم و پسوورد توی سایتمون کدوم یکی مورد نیازه؟؟؟؟ بله بله password .grant type

بعدش برای احراز هویت با سوشال مدیا ها در کنار احراز هویت عادی کدومش مورد نیازههه؟؟؟؟ آفرین هیچکدام . بعدشم خب ما که تو این حالتا پسوردی نداریم که باش لاگین کنیم تو برنامه لاراولیمون . پس چیکار کنیمممم؟؟؟؟ :( باید که یه grant type کاستوم برای خودمون بسازیم و یااا اینکه از یسری لایبرری های آماده استفاده کنیم که اونا واسمون اینکارو کنن و یاااا اینکه از همین passport grant type به یه شکل کلک طوری استفاده کنیم تا به کارمون بیاد . حالا وایستین اول خود socialite لاراول رو نصب کنیم تا تو مرحله ی کد نویسیش همشو بگم :

composer require laravel/socialite

بعدششش اگه که اون سوشالی که میخواین توی سایت لاراول بهش اشاره نشده (مثل الان ما که میخوایم از سوشال اپل استفاده کنیم) میتونید به لینک https://socialiteproviders.com/about/ مراجعه کنید و سوشال خودتون رو پیدا کنید و نصب کنید . الان پس برای کارمون ما اینو نصب میکنیم :

composer require socialiteproviders/apple

خیله خب الان دیگه نصب کردنیا تمام شد.

کانفیگ این سوشالایت اپل رو هم دقیقا طبق مراحلی که اینجا گفته انجام میدیم:

https://socialiteproviders.com/Apple/#installation-basic-usage

حالا مواد لازم برای لاگین با اپل رو که باید از پنل دولوپر اپل بدستش بیارین و کنار خودتون داشته باشین رو میگم:

  1. client id
  2. team id
  3. private key

صبوری کنید تا وقتش که برسه مورد استفاده ش رو هم بگممم.

خلاصه که مراحل احراز هویت از اول اول وقتی که با پلتفورم اپل لاگین میکنید تاااا وقتی که میاین با برنامه ی لاراولی ما لاگین کنید به شکل زیر هست:

دولوپر ios برای کاربرای برنامه ش گزینه ی لاگین با اپل رو میاره که بعد از کلیک روی اون گزینه،ازشون ایمیلی که باش توی اپل لاگین هستن و پسووردش رو میگیره و در صورت درست وارد کردن اطلاعات توسط کاربر و اتمام عملیات authentication به درستی، سرور اپل بنا به نوع درخواست و grant type که دولوپر ios ازش خواسته ، میتونه یه access token و refresh token و جزییات token رو در اختیارمون قرار بده، و یا اینکه یه authorization code بهمون بده. اما اینجا کدومش بدرد ما میخوره؟ authorization code. چرا؟؟؟؟ چون اون توکنی که اپل پس میده، فقط خود اپلیکیشن ios ازش مطمينه که حاصل یه لاگین موفق از سمت سرور اپل بوده و فقط واسه خودش بدرد میخوره. اما حالا که اپلیکیشن اپل تنها و مستقل نیست و داره از ما api میگیره پس باید از سمت برنامه ی ما توکن شناخته شده ای داشته باشه نه صرفا از سمت اپل . شاید بگین خیله خب همین توکن رو بیاد برای ما بفرسته ما هم همونو توی سیستم نگهداری کنیم و شناخته شدش کنیم و ازش استفاده کنیم. اما سوال اینجاس که : عاخه از کجا مطمین باشیم که این توکن دقیقا همونیه که سرور اپل به اپلیکیشن ios پس داده و بعدش اپلیکیشن هم برای ما فرستاده؟؟؟ ینی هر توکنی بیاد ما به همین راحتی بپذیریم و اطلاعاتشو بخونیم و تو دیتابیس ذخیره ش کنیم؟؟؟؟ نخیییییییر .شاید راه های ابتدایی برای اینکار باشن اما اگه بخوام راه اصولیشو بگم اینه کههه : وقتی اون authorization code به دستمون رسید باید خودمون یجوری بریم از سرور اپل بپرسیم که عاقا این کد واقعنی از سمت شما اومدهههه؟؟؟؟؟ حالا این درخواست که از سرور اپل همچین چیزی بپرسیم چارچوب و پارامترای خاصی داره و grant typeش Authorization Code هستش. بنااابر اینهمه توضیحااات (بعدا نیاین بگین پس توضیحاااااااااااات؟؟؟) ، در ابتدااای امر دولوپر ios از سرور اپل توی لاگین موفق باید درخواست authorization code بکنه و اون authorization code رو به ما پس بده و ما همین Authorization Code رو پست کنیم به سرور اپل تایید بشه که اون کد واقعی از سمت خود سرور اپل و حاصل لاگین موفقی بوده (به این مرحله هم میگن Authorization. توجه کنید که Authorization رو با Authentication اشتباه نگیرین)

چارچوب این ریکويست حتما باید به همین شکل باشه :

$data = [
    'client_id' => env(&quotAPPLE_CLIENT_ID&quot),
    'client_secret' => env(&quotAPPLE_CLIENT_SECRET&quot),
    'code' => $request->authorizationCode,
    'grant_type' => 'authorization_code',
];

و پستش میکنیم به سرور اپل :

$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'https://appleid.apple.com/auth/token');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));

$response = json_decode(curl_exec($ch));
curl_close($ch);

return $response;

اون مواد لازم رو یادتونه که گفتم باید از پنل دولوپر اپل برش دارین؟ الان اینجاها دیگه استفاده میشه. مقدار اول توی آرایه ی data یعنی client_id یکیشه که از پنل دولوپر اپل داریمش .

مقدار دوم رو هم باید خودمون بسازیم. چطوری؟ با داشتن client_id و یه کلید خصوصی (که این کلید خصوصی رو هم گفته بودم از پنل دولوپر اپل برمیداریم.برداشتین که انشالله؟) و بعدش ، یسری مقادیر رو کنار هم میذاریم (حالا توضیحشون میدم) و توسط اون کلید خصوصی که داریم امضاشون میکنیم. کد آماده ش به زبان Ruby هست :

require 'jwt'
key_file = 'key.txt'
team_id = 'ُTEAM_ID'
client_id = 'CLIENT_ID'
key_id = 'KEY_ID'
ecdsa_key = OpenSSL::PKey::EC.new IO.read key_file

headers = {
'alg': 'ES256',
'kid' => key_id
}

claims = {
    'iss' => team_id,
 'iat' => Time.now.to_i,
 'exp' => Time.now.to_i + 86400*180,
 'aud' => 'https://appleid.apple.com',
 'sub' => client_id,
}

token = JWT.encode claims, ecdsa_key, 'ES256', headers

puts token


متغیر key_file برابر آدرس اون اون فایلی هست که شامل کلید خصوصیه. اینجا من تو همین مسیری که این کد روبی هست قرارش میدم.

متغیر team_id و client_id و key_id رو هم از پنل دولوپر اپل گرفتین و توی کد بالا جایگزین میکنید.

و نهایتا کد بالا رو اجرا کنید تا توی خروجی shell بهتون اون secret_key ایجاد شده رو بده.(قطعا قبلشم که Ruby رو نصب کردین؟آفرین آفرین)
یه توضیح کوچیکم بدم که این secret_key و این فرایند امضا کردن این پارامترها باهم به چه درد میخوره. اون وقتی که ما داریم authorization code رو به اپل میفرستیم ، سرور اپل هم باید مطمين شه این ریکویست از سمت یه درخواست دهنده ی معتبر(که اینجا برنامه لاراولی ما هستش ) براش اومده و درواقع ارسال کننده رو شناسایی کنه و نهایتا فرآیند Authorize شکل بگیره و سرور اپل بهمون توکن مورد نیاز و جزییاتش رو بده.

توی این توکن یسری اطلاعاتی encode شده از جمله ایمیل اون کاربر . الان با توجه به اینکه خیالمون راحته که توکن از سمت اپل اومده میتونیم ایمیلی رو که توی توکن encode شده رو با استفاده از socialite لاراول decode کنیم و توی دیتابیس جدول users مون ثبت کنیم. به این منظور من یه کلاس AppleAuthenticationService ساختم و توش اینکارو انجام میدم:

socialUser = Socialite::driver('apple')->userFromToken($response->id_token);
$user = User::findByEmail($socialUser->email);

if ($user) {
    //Issue a token and return back it to user
} else {
    //Create user in database and then Issue a token and return back it to user
}

دقت کردین که برای گرفتن اطلاعات کاربر از جمله ایمیل کاربر از توکن توسط socialite (همون خط اول کد بالا منظورمه) نیاز به هیچ کلیدی نداریم و اون کلیدهایی که پنل دولوپر اپل دراختیارمون قرار میده صرفا برای مرحله قبل(authorize شدن توسط سرور اپل) هست؟ این توکن الان رو اگه الگوریتم کدگذاریشو بدونیم (که اینجا ES256 هست) از اونجایی هم که یه Bearer token هست براحتی میتونید حتی توی خود سایت jwt.io دیکریپتش کنید و محتویاتش رو ببینید. مهم هم نیست چون هدف اصلی از این فرایند شناسایی فرستنده بوده و اصولا اطلاعات حساسی توی توکن نیست (و البته قرارم نیست حتی از اون توکن برای ارتباط کلاینت با برناممون استفاده کنیم! فقط اطلاعات مورد نیازمونو ازش برمیداریم و استفاده میکنیم و تماام )

و یه چیز دیگه هم که باید دقت کنیم اینه که : ما توی این داستان لاگین و رجیستر با شبکه های اجتماعی، پسووردی نداریم . نهایت اطلاعاتی هم که از توکن میگیریم مثلا ایمیل طرف و اسم و فامیلشه . پس تو این مرحله باید که یه migration برای اصلاح جدول یوزر بسازیم و فیلد پسوورد رو nullable کنیم تا موقع ایجاد یوزر به مشکلی برنخوریم.(کدش رو دیگه اینجا قرار نمیدم)

حالا پسسس . اون یوزر ios بنده خدا اینهمه زحمت کشیده رفته اونور لاگین کرده به ما authorization code رسونده بعد ما باهاش توکنو گرفتیم بعد از توکنه اطلاعاتیو ک لازم داشتیم گرفتیم. حالا پس چجورییی با چه توکنی بیاد و با برنامه ما ارتباط برقرار کنه؟؟ ( دقیقا اون تیکه ای که توی کد بالا کامنت گذاشتما که: Issue a token . دقیقا منظورم اون تیکه هست که چطوری انجام میشه؟؟؟ صبر کنید کم کم به این تیکه هم میرسیم)


خیله خب اشکال نداره ما همینکه تو این مرحله مطمین شدیم یوزر قشنگمون اطلاعات کاربری اپل ش واقعا درست و معتبر بوده ، ایمیلشو تو دیتابیس ذخیره میکنیم و بلخره خودمون براش یه توکن میسازیم بهش میدیم که دیگه باش بتونه با برنامه ما ارتباط برقرار کنه. چجوری؟؟؟ اینجا خودش ۳ تا راه داره که یکی یکی میگم :

۱. یه grant_type کاستوم خودمون بسازیم که برامون کارای ثبت و ارسال توکن و جزییاتش رو انجام بده

۲. از همون password grant_type یطوری استفاده کنیم که به کارمون بیاد

۳. از یسری لایبرری استفاده کنیم تا خودش برامون این grant_type کاستوم رو ایجاد کنه.

راه اول :

برای این کار یه کلاس کاستوم برای خودمون ایجاد میکنیم که این کلاس باید خودش از کلاس AbstractGrant (که توی نصب لایبرری پاسپورت و oauth2 توی اون مراحل اولیه که توضیح دادم ،به فولدر vendor مون اضافه شده و کلا فرايندهای ساخت توکن و ... رو انجام میده) ، ارث ببره و ما یسری متدهاش رو به اون شکلی که به کار ما میاد override کنیم. من این کلاس رو توی فولدر app/Auth/Grants به این شکل میسازم:

<?php

namespace App\Auth\Grants;

use RuntimeException;
use Laravel\Passport\Bridge\User;
use League\OAuth2\Server\RequestEvent;
use Psr\Http\Message\ServerRequestInterface;
use League\OAuth2\Server\Grant\AbstractGrant;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;

class SocialGrant extends AbstractGrant
{
    /**
     * @param RefreshTokenRepositoryInterface $refreshTokenRepository
     */
    public function __construct(
        RefreshTokenRepositoryInterface $refreshTokenRepository
    ) {
        $this->setRefreshTokenRepository($refreshTokenRepository);

        $this->refreshTokenTTL = new \DateInterval('P1M');
    }

    /**
     * {@inheritdoc}
     */
    public function respondToAccessTokenRequest(
        ServerRequestInterface $request,
        ResponseTypeInterface $responseType,
        \DateInterval $accessTokenTTL
    ) {
        $client = $this->validateClient($request);
        $scopes = $this->validateScopes($this->getRequestParameter('scope', $request));
        $user = $this->validateUser($request, $client);

        // Finalize the requested scopes
        $scopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client, $user->getIdentifier());

        // Issue and persist new tokens
        $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $user->getIdentifier(), $scopes);
        $refreshToken = $this->issueRefreshToken($accessToken);

        // Inject tokens into response
        $responseType->setAccessToken($accessToken);
        $responseType->setRefreshToken($refreshToken);

        return $responseType;
    }

    protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client)
    {
        $username = $this->getRequestParameter('username', $request);
        if (is_null($username)) {
            throw OAuthServerException::invalidRequest('username');
        }

        $user = $this->getUserEntityByUserOtp(
            $username,
            $this->getIdentifier(),
            $client
        );

        if ($user instanceof UserEntityInterface === false) {
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));

            throw OAuthServerException::invalidCredentials();
        }

        return $user;
    }

    private function getUserEntityByUserOtp($username, $grantType, ClientEntityInterface $clientEntity)
    {
        $provider = config('auth.guards.api.provider');

        if (is_null($model = config('auth.providers.'.$provider.'.model'))) {
            throw new RuntimeException('Unable to determine authentication model from configuration.');
        }

        $user = (new $model)->where('username', $username)->first();

        if (is_null($user)) {
            return;
        }

        return new User($user->getAuthIdentifier());
    }

    /**
     * {@inheritdoc}
     */
    public function getIdentifier()
    {
        return 'social';
    }
}

خیله خب الان grant type مورد نظرمون با اسم دلخواه که من اسمشو گذاشتم social ساخته شد. مرحله ی بعد اینه که این grant type رو به پروژمون بشناسونیمش و درواقع enable ش کنیم. چطوری؟ میایم توی کلاس AuthServiceProvider و این خط رو توی متد boot بالا سر Passport:routes(); اضافه میکنیم:

app(AuthorizationServer::class)->enableGrantType(
    $this->makeSocialGrant(), Passport::tokensExpireIn()
);

و همچنان توی همین کلاس این متد رو هم آخر کلاس اضافه میکنیم:

protected function makeSocialGrant()
{
    $grant = new SocialGrant(
        $this->app->make(RefreshTokenRepository::class)
    );

    $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn());

    return $grant;
}

خیله خب بلخره grant type مورد نظرمون هم درست شد و آماده ی استفاده هست.

الان پس با خیال راحت میریم سراغ قسمتی که بتونیم بعد از ایجاد یوزر توی دیتابیسمون (یا اگه دیدیم قبلا وجود داشته)بهش توکن پس بدیم. پس اون کلاس AppleAuthenticationService رو که یه تیکه از کدش رو بالاترا قرار دادم تکمیلش میکنم:

socialUser = Socialite::driver('apple')->userFromToken($response->id_token);
$user = $this->userRepository->findByEmail($socialUser->email);

if ($user) {
    $tokenData = $this->issueToken($user,  $client);
} else {
    $user = $this->userRepository->store($userDataArray);//$userDataArray means data which we got from token
     $tokenData = $this->issueToken($user,  $client);
}


شاید سوال بشه که متغیر client از کجا اومد ؟ اون زمانی که داریم کارای passport رو میکنیم و براش migrate زدیم و بعد براش دستور php artisan passport:install رو زدیم . یه رکورد توی جدول oauth_clients برامون ثبت شده و برای کارمون اون رکورد رو از جدولش فچ کردیم که حاصلش شده client$. خیله خب حالا ببینیم که داخل متد issueToken چی میگذره:

$data = [
    'grant_type' => 'social',
    'client_id' => $client->id,
    'client_secret' => $client->secret,
    'username' => $user->username,
    'provider' => 'users'
];

$proxy = Request::create('oauth/token', 'POST', $data);
$oauth = app()->handle($proxy);
$data = json_decode($oauth->getContent(), true);

هورااااااااا ! روش اول تمام و توسط این کد ، توکن مورد نیازمون ایجاد شده، و بخشی از اون که لازمه ، توی جداول oauth_access_tokens و oauth_refresh_tokens ثبت شده و توی خروجی این درخواست هم توی متغیر data ، اون توکن و رفرش توکن و ایناش وجود داره که باید در اختیار کلاینت ios تون بدین. (البته اگه که دقیقا اطلاعات رو طبق چیزی که اینجا توضیح دادم وارد کرده باشین. درغیر این صورت ممکنه خطاهای مختلف بده. پس محض احتیاط بهتره که این request تون رو هم حالتای خطاش رو بررسی و هندل کنید (اونم کدش رو اینجا قرار ندادم اما حتمنی اینکارو بکنید.)

خیله خببب بریم سراغ روش دوم:

گفته بودم که توی این روش، میتونیم از همون password grant type که توی لاگین عادی، توسط یوزرنیم و پسوورد، توی پاسپورت انجام میشه استفاده کنیم. فقط اما باید به یسری نکات توی این روش خیلی توجه کنیم:

  • اینجا دیگه نیازی نیستش که فیلد پسوورد رو توی جدول users، nullable کنیم.
  • توی فایل env. باید یه مقدار مثلا به اسم SOCIAL_DEFAULT_PASSWORD تعریف کنیم و برابر با یه پسووردی قرارش بدیم.
  • موقع ایجاد یوزر توی جدول ،همین پسوورد رو براش هش کنیم و ست کنیم و حواسمون هم باشه موقع لاگین با سوشال مورد نظرمون هم، همین پسوورد رو براش ست کنیم.

درواقع کد درخواست توکن ش (ینی اینبار داخل متد issueToken مون که تو روش ۱ ساختیمش به جای اون کدای روش ۱، به این شکل هست)هم خیلی شبیه لاگین معمولی پاسپورت و به این شکل میشه :

$params = [
    'grant_type' => 'password',
    'client_id' => $client->id,
    'client_secret' => $client->secret,
    'username' => $user->username,
    'password' => env('SOCIAL_DEFAULT_PASSWORD'),
    'scope' => '*',
];

$proxy = Request::create('oauth/token', 'POST', $params);
$oauth = app()->handle($proxy);
$data = json_decode($oauth->getContent(), true);

return $data;

و دقیقا نتیجه ی کد و خروجیش شبیه روش ۱ هست که بالا توضیح دادم.

و اما روش سوم:

استفاده از یسری لایبرری ها مثل https://github.com/coderello/laravel-passport-social-grant هست که خودش برامون grant_type کاستوم و به اسم social میسازه و طریقه ی استفادش رو هم توی گیت هابش توضیح داده.

و تماااام... دمتون گرم که تا اینجای پست رو اومدین و خوندین . امیدوارم که براتون مفید باشه و به کارتون بیاد. البته توجه کنید که من بیشتر سعی کردم مفاهیم کلی و ضروری رو توضیح بدم و از نوشتن کدهای مربوط به جزییات متفرقه صرفنظر کردم. درکل اگه که جایی مشکلی داشتین یا خواستین که کدهارو تمامش رو داشته باشین بهم اطلاع بدین تا توی گیتهابم براتون کد کاملش رو بذارم. خوشحالم میشم که نظراتون رو هم بنویسین چه بسا کمک کنه جایی از کار رو بهبودش بدم.

راستی خودم هم برای پیاده سازی این کد و نوشتن این مطلب از این منابع استفاده کردم :

https://medium.com/@arifulislam_ron/create-custom-grant-token-in-laravel-passport-1ff0cc255dc5

https://laracasts.com/discuss/channels/laravel/using-socialitesocial-login-with-laravel-passport

https://itnext.io/laravel-api-authentication-for-social-networks-oauth2-social-grant-3ec1085b58b6