چرا سکان آکادمی؟
افزودن قابلیت لاگین کاربران در پروژهٔ RESTful API

افزودن قابلیت لاگین کاربران در پروژهٔ RESTful API

در آموزش گذشته دیدیم که به چه شکل می‌توان به عنوان یک کاربر جدید ثبت‌نام نمود؛ حال در ادامهٔ توسعهٔ این وب سرویس قصد داریم تا قابلیت لاگین کردن به سیستم و دریافت یک توکن از جنس JWT را به پروژه بیفزاییم. برای این منظور، داخل کلاس UserController متد جدیدی تحت عنوان ()auth به صورت زیر می‌سازیم:

public function auth()
{
    if ($this->requestMethod === 'POST') {
        $this->validateRequest();

        $this->validateParams('mail', $this->requestParams['mail'], true);
        $this->validateParams('pass', $this->requestParams['pass'], true);

        $user = new User((new Database())->connect());
        $result = $user->login($this->requestParams['mail'], $this->requestParams['pass']);
        $result = $result->fetchAll(\PDO::FETCH_ASSOC);

        if (!$result) {
            $this->returnResponse(401, 'Email or password is incorrect.');
        } else {
            $this->generateToken($result[0]['id']);
        }
    } else {
        $this->throwError(405, 'The only method that is acceptable is POST.');
    }
}

همچون متد ()signup که در آموزش گذشته مورد بررسی قرار دادیم، داخل متد ()auth نیز ابتدا به ساکن چک کرده‌ایم ببینیم که آیا ریکوئست ارسالی از جنس POST است یا خیر که اگر این گونه بود، داخل بلوک if با فراخوانی متد ()validateRequest پارامترهای ارسالی از سمت کلاینت را سِت کرده سپس با استفاده از متد ()validateParams از وجود کلید‌های mail و pass اطمینان حاصل کرده‌ایم.

همان‌طور که مشاهده می‌شود، داخل این متد آبجکتی از روی مُدل User ساخته‌ایم سپس متد ()login را روی آن فراخوانی کرده‌ایم و به عنوان پارامترهای ورودی این متد نیز کلیدهای mail و pass که پیش از این داخل پراپرتی requestParams$ ذخیره شده‌اند را در نظر گرفته‌ایم و نتیجه را در متغیری تحت عنوان result$ ذخیره کرده‌ایم. در ادامه، با استفاده از یک دستور شرطی تست کرده‌ایم ببینیم که آیا این متغیر حاوی مقداری است یا خیر که اگر پاسخ به این شرط false بود، ارور 401 با پیامی مبنی بر اینکه «ایمیل یا پسورد اشتباه است.» در معرض دید کاربر قرار می‌دهیم و در غیر این صورت وارد بلوک else شده و متدی تحت عنوان ()generateToken را فراخوانی نموده‌ایم که در حال حاضر ساخته نشده است. به طور کلی، این متد وظیفه دارد تا پس از کوئری زدن به جدول users و تطابق ایمیل و پسورد ارسالی از سمت کلاینت با آنچه قبلاً در دیتابیس ثبت شده است، یک توکن از جنس JWT ایجاد نماید به طوری که برای ارسال ادامهٔ ریکوئست‌ها می‌توان با ارسال آن توکن در بخش هِدِر، این تضمین را به وب سرویس بدهیم که کلاینتِ معتبری هستیم.

برای ساخت متد ()generateToken، کلاس BaseController را به صورت زیر تکمیل می‌کنیم:

<?php
namespace Core;

use Firebase\JWT\JWT;

class BaseController
{
    public $requestMethod;
    public $request;
    public $requestParams;

    const JWT_SECRET_KEY = "8dc2d81404c4ca0ba23b85b941696534";

    public function __construct()
    {
        $this->requestMethod = $_SERVER["REQUEST_METHOD"];
        $this->request = (array) json_decode(file_get_contents('php://input'), true);
    }

    public function throwError(int $statusCode, string $message)
    {
        header('Content-Type: application/json');
        echo json_encode([
            'error' => [
                'status' => $statusCode,
                'message' => $message,
            ],
        ]);
        die;
    }

    public function validateRequest()
    {
        if ($_SERVER['CONTENT_TYPE'] != 'application/json') {
            $this->throwError(403, 'The only acceptable content type is application/json.');
        }
        if (!isset($this->request['request_params']) || !is_array($this->request['request_params'])) {
            $this->throwError(400, 'The parameters of the request are not defined.');
        } else {
            $this->requestParams = $this->request['request_params'];
        }
    }

    public function validateParams($fieldName, $value, $isRequired = false)
    {
        if ($isRequired == true && $value == "") {
            $this->throwError(400, "The $fieldName field is required.");
        }
        return $value;
    }

    public function returnResponse($statusCode, $message)
    {
        header('Content-Type: application/json');
        header("Status: $statusCode");
        $http = [
            100 => '100 Continue',
            101 => '101 Switching Protocols',
            200 => '200 OK',
            201 => '201 Created',
            202 => '202 Accepted',
            203 => '203 Non-Authoritative Information',
            204 => '204 No Content',
            205 => '205 Reset Content',
            206 => '206 Partial Content',
            300 => '300 Multiple Choices',
            301 => '301 Moved Permanently',
            302 => '302 Found',
            303 => '303 See Other',
            304 => '304 Not Modified',
            305 => '305 Use Proxy',
            307 => '307 Temporary Redirect',
            400 => '400 Bad Request',
            401 => '401 Unauthorized',
            402 => '402 Payment Required',
            403 => '403 Forbidden',
            404 => '404 Not Found',
            405 => '405 Method Not Allowed',
            406 => '406 Not Acceptable',
            407 => '407 Proxy Authentication Required',
            408 => '408 Request Time-out',
            409 => '409 Conflict',
            410 => '410 Gone',
            411 => '411 Length Required',
            412 => '412 Precondition Failed',
            413 => '413 Request Entity Too Large',
            414 => '414 Request-URI Too Large',
            415 => '415 Unsupported Media Type',
            416 => '416 Requested Range Not Satisfiable',
            417 => '417 Expectation Failed',
            500 => '500 Internal Server Error',
            501 => '501 Not Implemented',
            502 => '502 Bad Gateway',
            503 => '503 Service Unavailable',
            504 => '504 Gateway Time-out',
            505 => '505 HTTP Version Not Supported',
        ];
        echo json_encode([
            'response' => [
                'code' => $http[$statusCode],
                'message' => $message,
            ],
        ]);
        die;
    }

    public function generateToken(int $userId)
    {
        try {
            $token = JWT::encode([
                'iat' => time(),
                'iss' => 'localhost',
                'exp' => time() + 60 * 60,
                'userId' => $userId,
            ], self::JWT_SECRET_KEY);

        } catch (\Exception $e) {
            echo $e->getMessage();
        }
        $this->returnResponse(200, ['token' => $token]);
    }

    public function notfound()
    {
        $this->returnResponse(404, 'The endpoint you are looking for is not found.');
    }
}

آنچه در این کلاس تغییر کرده آن است که در خط چهارم کلاس‌ JWT را به اصطلاح use کرده‌ایم چرا که قرار است تا از آن‌ داخل متدی تحت عنوان ()generateToken استفاده نماییم. در خط دوازدهم نیز یک کانستَنت تحت عنوان JWT_SECRET_KEY ساخته‌ایم و مقدار آن را برابر با یک استرینگ نسبتاً طولانی و غیرقابل‌حدس دلخواه قرار داده‌ سپس در انتهای این کلاس متدی تحت عنوان ()generateToken حاوی کدهای زیر ساخته‌ایم:

public function generateToken(int $userId)
{
    try {
        $token = JWT::encode([
            'iat' => time(),
            'iss' => 'localhost',
            'exp' => time() + 60 * 60,
            'userId' => $userId,
        ], self::JWT_SECRET_KEY);

    } catch (\Exception $e) {
        echo $e->getMessage();
    }
    $this->returnResponse(200, ['token' => $token]);
}

می‌بینیم که از ساختار try و catch استفاده کرده‌ایم و دلیلش هم آن است که ممکن است در حین ساخت توکن توسط کلاس JWT با مشکلی مواجه شویم. در واقع، چنانچه پروسهٔ ساخت توکن بدون هیچ‌ گونه مشکلی پیش رود، توکن ساخته‌شده را داخل متغیری تحت عنوان token$ ذخیره می‌سازیم و در غیر این صورت وارد بلوک catch شده و متد ()getMessage از کلاس Exception را روی آبجکت ساخته‌شده از روی این کلاس تحت عنوان e$ فراخوانی کرده و مقدار آن را چاپ می‌کنیم. در نهایت هم با استفاده از متد ()returnResponse یک کد وضعیت مرتبط همچون 200 به علاوهٔ کلیدی تحت عنوان token که مقدارش همان متغیر token$ است را ریترن می‌کنیم.

در ارتباط با نحوهٔ ساخت توکن با استفاده از کلاس JWT نیز باید گفت که داخل این کلاس متدی از جنس static داریم تحت عنوان ()encode که حداقل دو پارامتر ورودی الزامی دارا است بدین شکل که پارامتر اول آرایه‌ای است از یکسری مقادیر و پارامتر الزامی دوم نیز همان کانستَنت JWT_SECRET_KEY است که اصطلاحاً تحت عنوان Secret شناخته می‌شود.

نکته
متدهای استاتیک را می‌توان با استفاده از علائم :: پس از نام کلاس فراخوانی کرد.

در ارتباط با پارامتر اول که از جنس آرایه است باید گفت که کلید iat برگرفته از واژگان «Issue At» است که به نوعی مرتبط با زمان ساخت این توکن می‌باشد و هما‌ن‌طور که ملاحظه می‌شود، برای مقدار این کلید از تابع ()time استفاده نموده‌ایم. کلید بعد iss برگرفته از واژهٔ «Issuer» می‌باشد که قرار است تا تولیدکنندهٔ این توکن را در خود ذخیره سازد که در این مثال از استرینگ localhost استفاده کرده‌ایم. کلید exp از واژهٔ «Expiration» گرفته شده و دربرگیرندهٔ تاریخ انقضاء این توکن است و همان‌طور که می‌بینیم، به عنوان تاریخ انقضاء این توکن از تابع ()time به علاوهٔ 60 * 60 استفاده کرده‌ایم؛ به عبارتی،‌ این توکن از زمان ایجاد فقط یک ساعت معتبر خواهد بود و در نهایت هم کلیدی دلخواه همچون user_id در نظر گرفته و مقدار آن را برابر با پارامتر ورودی این متد تحت عنوان userId$ در نظر گرفته‌ایم. کل این آرایه به عنوان Payload در نظر گرفته شده سپس توسط الگوریتم‌های به کار رفته در این لایبرری هَش می‌شود.

روش تست قابلیت لاگین کردن کاربران

در این مرحله از کار، می‌توان با در نظر گرفتن اِندپیونت api/v1/auth نحوهٔ لاگین کردن به سیستم را تست کرد. برای این منظور، همچون نحوهٔ ثبت‌نام یک کاربر جدید، ابتدا متد POST را انتخاب نموده سپس در تَب مربوط به Headers نرم‌افزار Postman هِدِری تحت عنوان Content-Type با مقدار application/json سِت کرده و در ادامه روی تَب Body کلیک کرده فرمت جیسونی همچون آنچه در تصویر زیر مشاهده می‌کنید وارد می‌نماییم:

در واقع، با در نظر گرفتن دقیقاً همان ایمیل و پسوردی که در حین ثبت کاربر جدید انتخاب نمودیم و با کلیک بر روی دکمهٔ Send می‌بینیم که کلید token حاوی یک JWT است که از این پس با استفاده از آن می‌توانیم این پیام را به سرور بدهیم که کلاینتِ معتبری هستیم. در عین حال، می‌توانیم صحت عملکرد متد ()auth را با انجام یکسری تغییرات در ریکوئست ارسالی تست نماییم. به طور مثال، اگر نوع متد را از POST به متدی همچون GET تغییر دهیم، با ارور زیر مواجه خواهیم شد:

{
    "error": {
        "status": 405,
        "message": "The only method that is acceptable is POST."
    }
}

همچنین اگر در تَب Headers تیک گزینهٔ Content-Type را بر داریم و درخواست خود را ارسال کنیم، با ارور زیر مواجه خواهیم شد:

{
    "error": {
        "status": 403,
        "message": "The only acceptable content type is application/json."
    }
}

اگر هم در بخش Body جسیون مذکور به صورت زیر فاقد کلید pass باشد:

{
    "request_params": {
        "mail": "hi@example.com"
    }
}

با ارور زیر مواجه خواهیم شد:

{
    "error": {
        "status": 400,
        "message": "The pass field is required."
    }
}

می‌بینیم که متد ()validateParams به درستی پیامی مبنی بر اجباری بودن فیلد pass در معرض دیدمان قرار داده است.

جمع‌بندی
در این آموزش با تکمیل کلاس UserController قابلیت لاگین در این وب سرویس و دریافت توکنی از جنس JWT را اضافه نمودیم به طوری که از این پس کلاینت صرفاً نیاز خواهد داشت تا توکن مذکور را در اختیار وب سرویس قرار داده و به اِندپوینت‌هایی که سطح دسترسی محدود دارند دست پیدا کند.