使用 Sanctum 认证移动应用程序
发布于 作者: Alex Pestell
Sanctum 是 Laravel 的轻量级 API 认证包。在 我的上一篇文章 中,我介绍了如何使用 Sanctum 通过 Laravel API 认证 React SPA。本教程将介绍如何使用 Laravel Sanctum 认证移动应用程序。该应用程序将使用 Google 的跨平台应用程序开发工具包 Flutter 构建。由于本教程的重点不在移动应用程序上,因此我可能会跳过一些移动应用程序的实现细节。
本文末尾提供了最终代码的链接。
后端
我已经设置了 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/sanctumphp artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
现在,创建 books
迁移
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', 'password' => Hash::make('pwdpwd'),]);
现在为数据库播种:php artisan db:seed
。最后,创建路由和控制器操作。将以下内容添加到 routes/api.php
文件中
Route::get('book', [BookController::class, 'index']);
然后在 BookController
的 index
方法中,返回所有书籍
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
命令
share api.sanctum-mobile.test
控制台将输出一个 ngrok 页面,该页面将为您提供一个 URL(类似于 https://0c9775bd.ngrok.io
),将您的本地服务器公开到公共网络。(ngrok 的替代方案是 Beyond Code 的 Expose。) 因此,让我们创建一个 utils/constants.dart
文件来保存该 URL
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(); } ); }}
现在,我们只需要修改 main.dart
中的 MyApp
类来加载 BookList
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Sanctum Books', home: new Scaffold( body: BookList(), ) ); }}
现在,在您的测试设备或模拟器中启动它,您应该会看到一个书籍列表。
使用 Sanctum 进行身份验证
很好,因此我们知道 API 正在运行,并且可以从中获取书籍。下一步是设置身份验证。
我将使用 provider 包,并遵循官方文档中关于设置 简单状态管理 的指南。我想创建一个身份验证提供程序,该提供程序可以跟踪登录状态并最终与服务器通信。创建一个新文件 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; }}
使用此提供程序,我们现在可以检查是否已认证并相应地显示正确的页面。修改您的 main
函数以包含此提供程序
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
特性添加到我们的 User
模型中。这将为我们提供一个 tokens
关系,允许我们为用户创建和获取令牌,以及一个 createToken
方法。令牌本身是 40 个字符随机字符串的 sha256
散列:此字符串(未散列)将返回给客户端,客户端应将其保存以用于将来对 API 的任何请求。更准确地说,返回给客户端的字符串由令牌 ID 组成,后跟一个管道字符(|
),后跟明文(未散列)令牌。
因此,现在我们有了这个端点,让我们更新应用程序以使用它。login
方法现在必须将 email
、password
和 device_name
发布到此端点,如果它收到 200
响应,则将令牌保存到设备的存储中。对于 device_name
,我使用的是 device_info 包 来获取设备的唯一 ID,但实际上,此字符串是任意的。
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;}
由于我们没有使用 Web Guard,所以第一个条件被跳过。这让我们只剩下上面的代码。首先,它只在请求具有“Bearer”令牌时运行,即如果它包含以字符串“Bearer”开头的Authorization
标头。如果确实如此,它将调用PersonalAccessToken
模型上的findToken
方法
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;}
第一个条件检查令牌中是否包含管道字符,如果没有,则返回与令牌匹配的第一个模型。我假设这是为了保持与 2.3 之前的 Sanctum 版本的向后兼容性,该版本在将纯文本令牌返回给用户时,不包含管道字符。(这是拉取请求:原因是为了使令牌查找查询更高效。)无论如何,假设管道字符存在,Sanctum 会获取模型的 ID 和令牌本身,并检查哈希值是否与数据库中存储的值匹配。如果匹配,则返回模型。
回到Guard
:如果未返回令牌,或者我们正在考虑过期令牌(在本例中我们没有),则返回null
(在这种情况下身份验证失败)。最后
return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken( tap($accessToken->forceFill(['last_used_at' => now()]))->save()) : null;
检查tokenable
模型(即User
模型)是否支持令牌(换句话说,它是否使用HasApiTokens
特征)。如果不是,则返回null
- 身份验证失败。如果是,则返回此
$accessToken->tokenable->withAccessToken( tap($accessToken->forceFill(['last_used_at' => now()]))->save())
上面的示例使用了tap
帮助程序的单参数版本。这可用于强制 Eloquent 方法(在本例中为save
)返回模型本身。这里更新了访问令牌模型的last_used_at
时间戳。然后,保存的模型将作为参数传递给User
模型的withAccessToken
方法(它从HasApiTokens
特征获取)。这是一种更新令牌的last_used_at
时间戳并返回其关联的User
模型的简洁方法。这意味着身份验证已成功。
所以,回到应用程序。有了这种身份验证,我们需要更新应用程序对book
端点的调用,以在请求的Authorization
标头中传递令牌。为此,更新fetchBooks
方法以从Auth
提供程序获取令牌,然后将其添加到标头中
String token = await Provider.of<AuthProvider>(context, listen: false).getToken();final response = await http.get('$API_URL/book', headers: { 'Authorization': 'Bearer $token',});
不要忘记向AuthProvider
类添加getToken
方法
Future<String> getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('token');}
现在尝试再次登录,这次应该显示书籍。
API 和应用程序的最终代码可以在这里找到(包括注销功能)
- 后端:unlikenesses/sanctum-flutter-backend
- Flutter 应用程序:unlikenesses/sanctum-flutter-app