Використання Sanctum для автентифікації мобільного додатка


Використання Sanctum для автентифікації мобільного додатка

Sanctum - це легкий пакет автентифікації API Laravel. В цьому посібнику розглянемо використання Laravel Sanctum для автентифікації мобільного додатка. Додаток буде побудований у Flutter, наборі інструментів для розробки додатків від Google. Можемо пропустити деякі деталі реалізації мобільного додатка, оскільки це не є предметом цього керівництва.


Бекенд

Я створив Homestead для надання доменного імені api.sanctum-mobile.test, де буде обслуговуватися мій сервер, а також база даних MySQL.

Спочатку створимо додаток Laravel:

laravel new sanctum_mobile

На момент написання статті це дає мені новий проект Laravel (v8.6.0). Як і у підручнику SPA, API надаватиме список книг, тому я буду створювати ті самі ресурси:

php artisan make:model Book -mr

Позначки 'mr' також створюють міграцію та контролер.  Перш ніж ми будемо розбиратися з міграціями, давайте спочатку встановимо пакет Sanctum, оскільки нам знову знадобляться його міграції. 

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Тепер давайте створимо міграцю для 'book':

 

Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('author');
    $table->timestamps();
});

 Далі запускаємо міграцію для нашого додатку:

php artisan migrate

Якщо ви зараз заглянете в базу даних, ви побачите, що міграція Sanctum створила таблицю personal_access_tokens, яку ми будемо використовувати пізніше при автентифікації мобільного додатка.

Давайте оновимо DatabaseSeeder.php, щоб отримати кілька книг (і користувача на потім):

 

Book::truncate();
$faker = \Faker\Factory::create();
for ($i = 0; $i < 50; $i++) {
    Book::create([
        'title' => $faker->sentence,
        'author' => $faker->name,
    ]);
}
User::truncate();
User::create([
    'name' => 'Alex',
    'email' => 'alex@alex.com',
    'password' => Hash::make('pwdpwd'),
]);

Тепер засійте базу даних: php artisan db: seed.  Нарешті, створіть маршрут і дію контролера.  Додайте це до файлу routes / api.php:

Route::get('book', [BookController::class, 'index']);
 

а потім у методі index BookController поверніть усі книги: 

return response()->json(Book::all());

Перевіривши, що кінцева точка працює - curl https: //api.sanctum-mobile.test/api/book - пора запустити мобільний додаток.

Мобільний додаток 

Для мобільного додатка ми будемо використовувати Android Studio та Flutter.  Flutter дозволяє створювати крос-платформні програми, які повторно використовують один і той же код для пристроїв Android та iPhone.  Спочатку виконайте вказівки щодо встановлення Flutter та налаштування Android Studio, а потім запустіть Android Studio і натисніть «Створити новий проект Flutter».

Дотримуйтесь рецепту кулінарної книги Flutter, щоб отримати дані з Інтернету, щоб створити сторінку, яка отримує список книг з API.  Швидкий і простий спосіб представити наш API на пристрої Android Studio - скористатися командою спільного доступу Homestead:

share api.sanctum-mobile.test

Консоль видасть сторінку ngrok, яка дасть вам URL-адресу (щось на зразок https://0c9775bd.ngrok.io), яка відкриє ваш локальний сервер загальнодоступному.  (Альтернативою ngrok є Beyond Code’s Expose.) Тож давайте створимо файл utils / constants.dart, щоб помістити це в: 

const API_URL = 'http://191b43391926.ngrok.io';

Тепер повернемось до кулінарної книги Flutter.  Створіть файл books.dart, який міститиме класи, необхідні для нашого списку книг.  Спочатку клас Book для зберігання даних із запиту API:

class Book {
    final int id;
    final String title;
    final String author;
    Book({this.id, this.title, this.author});
    factory Book.fromJson(Map<String, dynamic> json) {
        return Book(
            id: json['id'],
            title: json['title'],
            author: json['author'],
        );
    }
}

 

Далі, клас BookList для отримання книг і виклик конструктора для їх відображення: 

class BookList extends StatefulWidget {
    @override
    _BookListState createState() => _BookListState();
}

class _BookListState extends State<BookList> {
    Future<List<Book>> futureBooks;

    @override
    void initState() {
        super.initState();
        futureBooks = fetchBooks();
    }

    Future<List<Book>> fetchBooks() async {
        List<Book> books = new List<Book>();
        final response = await http.get('$API_URL/api/book');
        if (response.statusCode == 200) {
            List<dynamic> data = json.decode(response.body);
            for (int i = 0; i < data.length; i++) {
                books.add(Book.fromJson(data[i]));
            }
            return books;
        } else {
            throw Exception('Problem loading books');
        }
    }

    @override
    Widget build(BuildContext context) {
        return Column(
            children: <Widget>[
                BookListBuilder(futureBooks: futureBooks),
            ],
        );
    }
}

 І нарешті, BookListBuilder для відображення книг:

class BookListBuilder extends StatelessWidget {
    const BookListBuilder({
        Key key,
        @required this.futureBooks,
    }) : super(key: key);

    final Future<List<Book>> futureBooks;

    @override
    Widget build(BuildContext context) {
        return FutureBuilder<List<Book>>(
            future: futureBooks,
            builder: (context, snapshot) {
                if (snapshot.hasData) {
                    return Expanded(child: ListView.builder(
                        itemCount: snapshot.data.length,
                        itemBuilder: (context, index) {
                            Book book = snapshot.data[index];
                            return ListTile(
                                title: Text('${book.title}'),
                                subtitle: Text('${book.author}'),
                            );
                        },
                    ));
                } else if (snapshot.hasError) {
                    return Text("${snapshot.error}");
                }
                return CircularProgressIndicator();
            }
        );
    }
}

Тепер нам просто потрібно змінити клас MyApp у main.dart, щоб завантажити BookList:

class MyApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Sanctum Books',
            home: new Scaffold(
                body: BookList(),
            )
        );
    }
}

 Тепер запустіть це на своєму тестовому пристрої або в емуляторі, і ви побачите список книг.

Аутентифікація за допомогою Sanctum

Чудово, тепер ми знаємо, що API працює, і що ми можемо дістати з нього книги.  Наступним кроком є ​​налаштування автентифікації.

Я збираюся скористатися пакетом провайдера та дотримуватися вказівок в офіційній документації для налаштування простого державного управління.  Я хочу створити постачальника автентифікації, який відстежує статус входу та врешті-решт спілкується із сервером.  Створіть новий файл auth.dart.  Ось куди піде функція автентифікації. На даний момент ми повернемо true, щоб ми могли перевірити, як працює процес:

class AuthProvider extends ChangeNotifier {
    bool _isAuthenticated = false;

    bool get isAuthenticated => _isAuthenticated;

    Future<bool> login(String email, String password) async {
       print('logging in with email $email and password $password');
        _isAuthenticated = true;
        notifyListeners();
        return true;
    }
}

За допомогою цього провайдера ми тепер можемо перевірити, чи ми автентифіковані, і відповідно відобразити правильну сторінку.  Змініть свою основну функцію, щоб включити провадера: 

void main() {
    runApp(
        ChangeNotifierProvider(
            create: (BuildContext context) => AuthProvider(),
            child: MyApp(),
        )
    );
}

… та змініть клас MyApp, щоб показати віджет BookList, якщо ми ввійшли в систему, або віджет LoginForm інакше: 

body: Center(
    child: Consumer<AuthProvider>(
        builder: (context, auth, child) {
            switch (auth.isAuthenticated) {
                case true:
                    return BookList();
                default:
                    return LoginForm();
            }
        },
    )
),

Класи LoginForm містять багато "віджетних" суттєвостей, тому я перенаправлю вас до репозиторію GitHub, якщо вам цікаво його переглянути.  У будь-якому випадку, якщо ви завантажуєте програму на свій тестовий пристрій, ви побачите форму для входу.  Заповніть випадковий електронний лист і пароль, надішліть форму, і ви побачите список книг.

Гаразд, давайте налаштуємо серверну систему для обробки автентифікації.  Документи повідомляють нам про створення маршруту, який буде приймати ім’я користувача та пароль, а також ім’я пристрою та повертати маркер.  Тож давайте створимо маршрут у файлі api.php:

Route::post('token', [AuthController::class, 'requestToken']);

 і контролер: php artisan make: controller AuthController.  Тут буде міститися код із документів:

public function requestToken(Request $request): string
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    return $user->createToken($request->device_name)->plainTextToken;
}

За умови, що ім’я користувача та пароль є дійсними, це створить маркер, збереже його у базі даних та поверне клієнту.  Щоб це працювало, нам потрібно додати ознаку HasApiTokens до нашої моделі користувача.  Це дає нам взаємозв'язок токенів, що дозволяє нам створювати та отримувати маркери для користувача, а також метод createToken.  Сам маркер є хешем sha256 40-символьного випадкового рядка: цей рядок (негешований) повертається клієнту, який повинен зберегти його для використання з будь-якими майбутніми запитами до API.  Точніше, рядок, що повертається клієнтові, складається з ідентифікатора маркера, за яким слідує символ конвеєра (|), за яким слідує маркер простого тексту (негешований).

Тож тепер у нас є ця кінцева точка, давайте оновимо додаток, щоб використовувати його.  Тепер метод входу повинен буде розмістити електронну пошту, пароль та ім’я_пристрою до цієї кінцевої точки, а якщо отримає відповідь 200, збережіть маркер у сховищі пристрою.  Для імені пристрою я використовую пакет device_info, щоб отримати унікальний ідентифікатор пристрою, але насправді цей рядок є довільним.

final response = await http.post('$API_URL/token', body: {
    'email': email,
    'password': password,
    'device_name': await getDeviceId(),
}, headers: {
    'Accept': 'application/json',
}); 

if (response.statusCode == 200) {
    String token = response.body;
    await saveToken(token);
    _isAuthenticated = true;
    notifyListeners();
}

 Я використовую пакет shared_preferences, який дозволяє зберігати прості пари ключ-значення, щоб зберегти токен:

saveToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('token', token);
}

Отже, ми отримали програму, яка відображає сторінку книг після успішного входу.  Але, звичайно, до речі, книги доступні з успішним входом або без нього.  Спробуйте: curl https: //api.sanctum-mobile.test/api/book.  Тож давайте захистимо маршрут:

Route:::middleware('auth:sanctum')->get('book', [BookController::class, 'index']); 

Увійдіть знову за допомогою програми, і цього разу ви отримаєте повідомлення про помилку: "Проблема із завантаженням книг".  Ви успішно аутентифікуєтесь, але оскільки ми ще не надсилаємо маркер API із нашим запитом на отримання книг, API цілком справедливо не надсилає їх.  Як і в попередньому навчальному посібнику, давайте подивимось на захистника Sanctum, щоб побачити, що він тут робить: 

if ($token = $request->bearerToken()) {
    $model = Sanctum::$personalAccessTokenModel;

    $accessToken = $model::findToken($token);

    if (! $accessToken ||
        ($this->expiration &&
         $accessToken->created_at->lte(now()->subMinutes($this->expiration))) ||
         ! $this->hasValidProvider($accessToken->tokenable)) {
        return;
    }

    return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken(
        tap($accessToken->forceFill(['last_used_at' => now()]))->save()
    ) : null;
}

Перша умова пропущена, оскільки ми не використовуємо веб-захист.  Що залишає нам вищевказаний код.  По-перше, він виконується лише в тому випадку, якщо в запиті є маркер “Bearer”, тобто якщо він містить заголовок Authorization, який починається зі рядка “Bearer”.  Якщо це станеться, він викличе метод findToken у моделі PersonalAccessToken:

if (strpos($token, '|') === false) {
    return static::where('token', hash('sha256', $token))->first();
}

[$id, $token] = explode('|', $token, 2);

if ($instance = static::find($id)) {
    return hash_equals($instance->token, hash('sha256', $token)) ? $instance : null;
} 

Перший умовний перевіряє, чи є символ маркера в маркері, а якщо ні, то повертає першу модель, яка відповідає маркеру.  Я припускаю, що це для збереження зворотної сумісності з версіями Sanctum до 2.3, яка не включала символ конвеєра в маркер простого тексту при поверненні його користувачеві.  .  зберігаються в базі даних.  Якщо так, модель повертається.

Назад у Guard: якщо жоден токен не повертається, або якщо ми розглядаємо термін дії термінів, що закінчуються (що в даному випадку ми не маємо), поверніть null (у цьому випадку автентифікація не вдається).  І нарешті:

return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken(
    tap($accessToken->forceFill(['last_used_at' => now()]))->save()
) : null;

 Переконайтеся, що токемована модель (тобто модель користувача) підтримує маркери (іншими словами, що вона використовує ознаку HasApiTokens).  Якщо ні, поверніть значення null - автентифікація не вдається.  Якщо так, то поверніть це:

$accessToken->tokenable->withAccessToken(
    tap($accessToken->forceFill(['last_used_at' => now()]))->save()
)

він у наведеному вище прикладі використовує одноаргументну версію допоміжного пристрою.  Це можна використовувати, щоб змусити метод Eloquent (у цьому випадку зберегти) повернути саму модель.  Тут оновлена ​​мітка часу останнього використання моделі маркера доступу.  Потім збережена модель передається як аргумент методу UserAccessToken (який вона отримує з ознаки HasApiTokens).  Це компактний спосіб оновлення мітки часу last_used_at маркера та повернення пов'язаної з ним моделі користувача.  Це означає, що автентифікація була успішною.

Отже, повернімось до програми.  Після цієї автентифікації нам потрібно оновити виклик програми до кінцевої точки книги, щоб передати маркер у заголовку авторизації запиту.  Для цього оновіть метод fetchBooks, щоб захопити маркер у постачальника автентифікації, а потім додайте його в заголовок:

 

String token = await Provider.of<AuthProvider>(context, listen: false).getToken();
final response = await http.get('$API_URL/book', headers: {
    'Authorization': 'Bearer $token',
});

Не забудьте додати метод getToken до класу AuthProvider:

Future<String> getToken() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString('token');
}

 

Тепер спробуйте увійти ще раз, і цього разу книги повинні бути відображені.

Джерело