1 апреля 2016.
Новостная рассылка
Присоединяйтесь к нашему новостному бюллетеню и не пропускайте новости из мира Laravel, анонсы полезных пакетов и советы опытных разработчиков.

Разработка API для сторонних приложений (Laravel 5)

Автор статьи Mohamed Said Mohamed Said | Перевод Building an API for 3rd party applications | Виталий Николенко

API - крутая вещь, и Laravel позволяет воплотить в жизнь даже самый крутой из API.
В этой статье поговорим о создании API для сторонних приложений, дадим им возможность общаться с нашим приложением от имени зарегистрированных пользователей.

Планируем функционал

Наше приложение должно разрешать сторонним приложением возможность читать/писать данные, мы должны уметь ограничивать доступ только "знакомым" нам приложениям, также мы должны иметь возможность включить/отключить доступ любому приложениею в любое время, вот список функций для реализации:

  • Выдать приложению аутентификационный токен.
  • Деактивировать приложение, и не обрабатывать более его запросы.
  • Разрешить пользователям логиниться через стороннние приложения.
  • Разрешить пользователям разлогиниваться через стороннние приложения.

Установка свежего проекта Laravel

Для демонстрационных целей установим свежий проект laravel и назовем его "Valhalla":

composer create-project laravel/laravel valhalla

Подготовка файлов

Добавим в app/Http/routes.php:

$router->group(['prefix' => 'api/v1'], function ($router) {
    // Аутентификация приложений...
    $router->post('/auth/app', 'Api\AuthController@authenticateApp');

    // Аутентификация пользователей...
    $router->post('/auth/user', 'Api\AuthController@authenticateUser')->middleware('auth.api.app');
    $router->post('/auth/user/logout', 'Api\AuthController@logoutUser')->middleware('auth.api.user');

    // Тестовые  маршруты
    $router->get('/application-data', 'Api\HomeController@appData');
    $router->get('/user-data', 'Api\HomeController@userData');
});

// авторизация приложения для доступа к данным пользователя...
$router->get('/authorize', 'HomeController@showAuthorizationForm')->middleware('web');
$router->post('/authorize', 'HomeController@authorizeApp')->middleware('web');

Создадим app/Http/Controllers/Api/AuthController.php

class AuthController extends Controller
{
    public function authenticateApp(Request $request){}

    public function authenticateUser(Request $request){}

    public function logoutUser(Request $request){}
}

Создадим app/Http/Controllers/Api/HomeController.php

class HomeController extends Controller
{
    public function appData(Request $request){}

    public function userData(Request $request){}
}

И еще app/Http/Controllers/HomeController.php

class HomeController extends Controller
{
    public function authorizeApp(Request $request){}
}

Также нам нужна модель для Приложения:

php artisan make:model Application

Подготовка базы данных

Для данного приложения нам понадобится таблица с пользователями, и таблица с приложениями. Для таблицы users миграция поставляется вместе с установкой laravel, поэтому остается только создать таблицу с приложениями:

php artisan make:migration create_applications_table --create=applications

Структура таблицы будет такой:

Schema::create('applications', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('key')->unique();
    $table->string('secret');
    $table->tinyInteger('is_active')->unsigned()->default(1);
    $table->timestamps();
});

Создадим связующую таблицу для пользоватлей и разрешенных приложений:

php artisan make:migration create_application_user_table --create=application_user

Schema::create('application_user', function (Blueprint $table) {
    $table->integer('application_id')->unsigned();
    $table->integer('user_id')->unsigned();
    $table->string('authorization_code')->nullable();

    $table->primary(['application_id', 'user_id']);

    $table->foreign('application_id')->references('id')->on('applications')->onDelete('cascade');
    $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});

Теперь давайте добавим немного данных. Зарегистрируем приложение "Asgard Connect" с ключом и секретным словом:

insert into `applications` (name, key, secret) values ('Asgard Connect', '111222333', 'aaabbbccc');

Добавим пользователя:

insert into `users` (name, email, password) values ('Loki', 'loki@asgard.com', '$2y$10$QfBGX14wKpXT/zA1gR.FZ.A12nrXzbtfki8wfqfwG.irvAWAYE9dC');

Аутентификация приложений

Чтобы иметь возможность идентифицировать и аутентифицировать приложения, которые пытаются обращаться к нашему API, каждому приложению мы присваиваем ключ и секретное слово, из них приложение создает хэш, с помощью которого обращается к нам за аутентификацией.

Мы будем использовать JSON веб токен (JWT), считаю, что это идеальный подход, отличная комбинация безопасности и простоты. Для этого подключим библиотеку JWT:

composer require firebase/php-jwt

Запрос токена

Для получения токена приложение должно предоставить закодированную в base64 пару "ключ:секрет" (через двоеточие). Примерно так:

base64_encode('111222333:aaabbbccc');
// Результат: MTExMjIyMzMzOmFhYWJiYmNjYw==

Затем приложение отправляет POST запрос по адресу /auth/app со следующим заголовком:

Authorization: Basic MTExMjIyMzMzOmFhYWJiYmNjYw==

Мы же в методе контроллера AuthController@authenticateApp обработаем запрос следующим образом:

public function authenticateApp(Request $request)
{
    $credentials = base64_decode(
        Str::substr($request->header('Authorization'), 6)
    );

    try {
        list($appKey, $appSecret) = explode(':', $credentials);

        $app = Application::whereKeyAndSecret($appKey, $appSecret)->firstOrFail();
    } catch (\Throwable $e) {
        return response('invalid_credentials', 400);
    }

    if (! $app->is_active) {
        return response('app_inactive', 403);
    }

    return response([
        'token_type' => 'Bearer',
        'access_token' => $app->generateAuthToken(),
    ]);
}

Токен генерится в Application@generateAuthToken:

public function generateAuthToken()
{
    $jwt = \Firebase\JWT::encode([
        'iss' => 'valhalla',
        'sub' => $this->key,
        'iat' => time(),
        'exp' => time() + (5 * 60 * 60),
    ], 'w5yuCV2mQDVTGmn3');

    return $jwt;
}

При помощи библиотеки Firebase\JWT был сгенерирован токен и подписан секретным словом. Секретное слово можно вынести в .env, но для упрощения привожу его непосредственно в коде.

Из чего состоит JWT :

  • iss Издатель токена, в нашем случае наше приложение "valhalla".
  • sub Субъект токена, т.е. приложение, которое пытается получить доступ.
  • iat Время выдачи токена.
  • exp Время смерти токена (мы даем ему пожить 5 часов).

Аутентификационный ответ получается таким:

{
    "token_type": "Bearer",
    "access_token": "eyJ0eXAiO~~~.eyJpc3MiO~~~.MSzBigimzWrc9DlZZduh~~~"
}

Приложение обязано сохранить токен, и отправлять его затем с каждым запросом.

Делаем запросы

Для того чтобы сделать запрос, который требует аутентификации, необходимо отправить токен в заголовке Authorization:

Authorization: Bearer eyJ0eXAiO~~~.eyJpc3MiO~~~.MSzBigimzWrc9DlZZduh~~~

Но для начала давайте создадим мидлваре (посредника) для проверки этого токена:

php artisan make:middleware ApiAppAuth

Добавим обработку входящего запроса:

public function handle($request, Closure $next)
{
    $authToken = $request->bearerToken();

    try {
        // проверка валидности токена
        $this->payloadIsValid(
              // JWT::decode  принимает строку с токеном первым аргументом
              // затем ключ, которым закодирован токен
              //  и список алгоритмов
            $payload = (array) JWT::decode($authToken, 'w5yuCV2mQDVTGmn3', ['HS256'])
        );

        $app = Application::whereKey($payload['sub'])->firstOrFail();
    } catch (\Firebase\JWT\ExpiredException $e) {
        return response('token_expired', 401);
    } catch (\Throwable $e) {
        return response('token_invalid', 401);
    }

    if (! $app->is_active) {
        return response('app_inactive', 403);
    }

     // Получив инстанс аутентифицированного приложения
     // передаем его в Request. Это позволит нам 
     // иметь легкий доступ к  инстансу приложения повсеместно.
     $request->merge(['__authenticatedApp' => $app]);

    return $next($request);
}

private function payloadIsValid($payload)
{
    $validator = Validator::make($payload, [
        'iss' => 'required|in:valhalla',
        'sub' => 'required',
    ]);

    if (! $validator->passes()) {
        throw new \InvalidArgumentException;
    }
}

Теперь пропишем нашего посредника в Http/Kernel.php:

protected $routeMiddleware = [
    'auth.api.app' => \App\Http\Middleware\ApiAppAuth::class,
    // ... остальные посредники
];

И наконец укажем его в маршруте /application-data:

$router->get('/application-data', 'Api\HomeController@appData')->middleware('auth.api.app');

Теперь, если посетить страницу /api/v1/application-data, предоставив нужный авторизационный заголовок получим ответ 200, тело ответа можно задать в HomeController@appData, в нашем примере мы отдаем объект аутентифицированного приложения в виде JSON:

public function appData(Request $request)
{
    return $request->__authenticatedApp;
}

Если токен невалиден, получим ответ 401, в случае, елси доступ приложению закрыт - ответ 403.

Обработка просроченных токенов

Если время жизни токена истекло, ответ будет token_expired с кодом 401, приложение, которое обращается по API должно предусматривать такие ситуации и произвести получение нового токена в таком случае.

Деактивация приложения

Если в какой-то момент времени, вы захотите деактивировать конкретное приложение, с целью закрыть ему доступ к апи, просто смените атрибут is_active на 0, остальное возьмет на себя посредник auth.api.app.

Если запрос делает неактивное приложение - оно получит ответ app_inactive с кодом ответа 403.

Аутентификация пользователей

Сейчас в нашем приложении "Valhalla" мы разрешаем сторонним приложениям делать запросы, не связанные с пользователям и их данными, но нам также хотелось бы разрешить пользователям входить в систему через приложения и разрешать приложению делать определенные действия от имени пользователей.

Однако мы не хотим, чтобы приложения имели доступ к личным данным пользователя просто так, если пользователь желает использовать "Valhalla" из стороннего приложения, ему необходимо зайти к нам на страницу, пройти идентификацию и разрешить "Valhalla" предоставить доступ приложению к свои личным данным. Вот как мы это сделаем:

  • Поьзователь запускает стороннее приложение.
  • Кликает "Войти".
  • Идет переход на определенный url Valhalla .
  • Пользователь входит с логином и паролем.
  • Идет редирект на специальный URI с кодом.
  • Приложение использует этот код для запроса токена аутентификации пользователя
  • Приложение использует полученный токен для будущих запросов

Авторизация

В методе app/Http/Controllers/HomeController@showAuthorizationForm проверим авторизационные параметры и отобразим, при необходимости форму для логина:

public function authorizeApp(Request $request)
{
    $validator = Validator::make($request->all(), [
        'app_key' => 'required|exists:applications,key,is_active,1',
        'redirect_uri' => 'required:active_url',
    ]);

    if (! $validator->passes()) {
        return view('authorize-app')->withInvalid('true');
    }

    $app = Application::whereKey($request->app_key)->first();

    return view('authorize-app', compact('app'));
}

Если запрос невалиден, покажем шаблон с переменной $invalid, елси проверка пройдена успешно отобразим шаблон с инстансом приложения, котрое пытается получить доступ к токену.

Шаблон (resources/views/authorize-app.blade.php) содержит простую форму:

@if(isset($invalid))
    Авторизация не прошла.
@else
    <div class="error"> Разрешить приложению  "{{$app->name}}" доступ к личным данным. </div>

    @if(session('message'))
        <div class="error"> {{session('message')}} </div>
    @endif

    <form method="POST" action="{{ url('/authorize') }}">
        {!! csrf_field() !!}
        <input type="hidden" name="app_key" value="{{ request('app_key') }}">
        <input type="hidden" name="redirect_uri" value="{{ request('redirect_uri') }}">

        <input type="email" name="email">
        <input type="password" name="password">

        <button type="submit">authorize</button>
    </form>
@endif

Теперь пользователю необходимо заполнить и отправить форму, тем самым давая разрешение стороннему приложению на доступ к своим данным.

Обработка разрешения пользователя на авторизацию

Отправленную форму обработает следующий метод HomeController@authorizeApp:

public function authorizeApp(Request $request)
{
     // Проверка параметров валидации...
    $validator = Validator::make($request->all(), [
        'app_key' => 'required|exists:applications,key,is_active,1',
        'redirect_uri' => 'required:active_url',
    ]);

    if (! $validator->passes()) {
        return redirect()->back()->withMessage('Неверные параметры авторизации');
    }

     // Проверим логин/пароль пользователя...
    if (! Auth::validate($request->only(['email', 'password']))) {
        return redirect()->back()->withMessage('Неверный логин или пароль');
    }

    $app = Application::whereKey($request->app_key)->first();

    $user = User::whereEmail($request->email)->first();

     // Генерация кода авторизации для приложения..
    $pivotData = ['Authorization_code' => $code = sha1($app->id.':'.$user->id.str_random())];

     // Обновим данные связующей таблицы...
    if ($app->users->contains($user)) {
        $app->users()->updateExistingPivot($user->id, $pivotData);
    } else {
        $app->users()->attach($user->id, $pivotData);
    }

     // Перейдем по указанному  redirect_uri с кодом...
    return redirect()->away($request->redirect_uri.'?code='.$code);
}

Теперь, при условии что пользователь ввел верные данные, произойдет переход по заданному url с кодом redirect_uri?code=6bc02273a757569a0237, приложение должно обработать этот момент и забрать код из урл, чтобы выписать токен для доступа к личным данным.

Выпуск аутентификационного токена

Возвращаемся к app\Http\Controllers\Api\AuthController.php, добавим метод для выпуска токена для пользователя.

public function authenticateUser(Request $request)
{
    $code = $request->json('code');

    $app = $request->__authenticatedApp;

    if (! $code || ! $user = $app->users()->wherePivot('Authorization_code', $code)->first()) {
        return response('invalid_code', 400);
    }

    $app->users()->updateExistingPivot($user->id, ['Authorization_code' => null]);

    return response([
        'token_type' => 'Bearer',
        'access_token' => $user->generateAuthToken($app),
    ]);
}

А вот как происходит сама генерацим токена в модели app\User.php:

public function generateAuthToken(Application $app)
{
    $jwt = JWT::encode([
        'iss' => $app->key,
        'sub' => $this->email,
        'iat' => time(),
        'jti' => sha1($app->key.$this->email.time()),
    ], 'w5yuCV2mQDVTGmn3');

    return $jwt;
}

jti - уникальный идентификатор токена, позже мы используем его, чтобы разлогинить пользователя и запретить использование данного токена в будущем.

Заметили, что теперь издателем токена является стороннее приложение, поэтому передаем его ключ для дальнейшего использования. И также токен не имеет срока жизни. Позже добавим механизм логаута.

Для генерации токена приложение должно отправить код по адресу /auth/user в виде json и получить токен в ответ.

Делаем запросы

Чтобы сделать запрос, который требует чтобы пользователь был аутентифицирован, необходимо послать пользовательский токен в заголовке Authorization:

Authorization: Bearer eyJ0eXAiO~~~.eyJpc3MiO~~~.MSzBigimzWrc9DlZZduh~~~

Но для начала, добавим посредника для проверки пользовательского токена: php artisan make:middleware ApiUserAuth

В нем обработаем входящий запрос:

public function handle($request, Closure $next)
{
    $authToken = $request->bearerToken();

    try {
        $this->payloadIsValid(
            $payload = (array) JWT::decode($authToken, 'w5yuCV2mQDVTGmn3', ['HS256'])
        );

        $app = Application::whereKey($payload['iss'])->firstOrFail();

        $user = User::whereEmail($payload['sub'])->firstOrFail();
    } catch (\Throwable $e) {
        return response('token_invalid', 401);
    }

    if (! $app->is_active) {
        return response('app_inactive', 403);
    }

    $request->merge(['__authenticatedApp' => $app]);

    $request->merge(['__authenticatedUser' => $user]);

    return $next($request);
}

private function payloadIsValid($payload)
{
    $validator = Validator::make($payload, [
        'iss' => 'required',
        'sub' => 'required',
        'jti' => 'required',
    ]);

    if (! $validator->passes()) {
        throw new \InvalidArgumentException;
    }
}

Пропишем мидлваре в Http/Kernel.php:

protected $routeMiddleware = [
    'auth.api.user' => \App\Http\Middleware\ApiUserAuth::class,
    // ... .
];

И наконец добавим в маршррут /user-data:

$router->get('/user-data', 'Api\HomeController@appData')->middleware('auth.api.user');

Теперь, если посетить страницу /api/v1/user-data, предоставив нужный авторизационный заголовок получим ответ 200, тело ответа можно задать в HomeController@userData:

public function userData(Request $request)
{
    return [
        'app' => $request->__authenticatedApp,
        'user' => $request->__authenticatedUser,
    ];
}

Логаут пользователя

Теперь пользователь может использовать стороннее приложение для доступа к личным данным, однако, нам надо также иметь возможность разлогинить пользователя, дать выйти из системы. Для этого создадим таблицу tokens_cemetery в которой будем "хоронить" пользовательские токены которые больше не нужны: php artisan make:migration create_tokens_cemetery_table --create=tokens_cemetery

А вот и схема:

Schema::create('tokens_cemetery', function (Blueprint $table) {
    $table->string('token_id');
});

Добавим в метод Api\AuthController@logoutUser следующий функционал:

public function logoutUser(Request $request)
{
    $token = $request->bearerToken();

    DB::table('tokens_cemetery')->insert(['token_id' => $token]);

    return response('token_deceased');
}

И обновим посредника ApiUserAuth добавив в него проверку мертвых токенов:

// Добавим после:
// if (! $app->is_active) {
//    return response('app_inactive', 403);
// }

if (DB::table('tokens_cemetery')->whereTokenId($payload['jti'])->first()) {
    return response('token_deceased', 403);
}

$request->merge(['__authTokenId' => $payload['jti']]);

Теперь, если пользователь разлогинился, запросы по API c мертвым токеном получат код 403 и ответ token_deceased, приложение должно попросить пользователя заново войти в систему и разрешить далее использовать свои личные данные и осуществлять действия о имени пользователя.

Заключение

Возможно вы заметили, что в данном уроке мы рассмотрели выпуск двух видов токенов, один для аутентификации стороннего приложения, второй для аутентификации пользователя. При написании документации к нашему API мы должны указать какие группы запросов требуют один тип токена, а какие - второй.

На это все, можете приглашать разработчиков сторонних приложений к использованию своего API!




Разработка пакета для Laravel 5. Пошаговая инструкция с картинками.

Небольшой урок по созданию своего пакета для фреймворка Laravel. На примере формы заказа обратного звонка

Создание собственных Blade директив в Laravel

Небольшой пример разработки собственной директивы шаблонизатора Blade.