使用 Sanctum 认证移动应用程序

发布于 作者:

Using Sanctum to authenticate a mobile app image

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/sanctum
php 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',
'email' => '[email protected]',
'password' => Hash::make('pwdpwd'),
]);

现在为数据库播种:php artisan db:seed。最后,创建路由和控制器操作。将以下内容添加到 routes/api.php 文件中

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

然后在 BookControllerindex 方法中,返回所有书籍

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 方法现在必须将 emailpassworddevice_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 和应用程序的最终代码可以在这里找到(包括注销功能)

Alex Pestell photo

柏林 fortrabbit 的全栈开发人员。

归档于
Cube

Laravel 新闻稿

加入 40,000 多名其他开发者,永远不要错过新的提示、教程等等。

Laravel Forge logo

Laravel Forge

轻松创建和管理服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和对本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

无妥协

Joel 和 Aaron,两位来自 No Compromises 播客的经验丰富的开发者,现在可以为您的 Laravel 项目聘用。⬧ 固定价格 7,500 美元/月。⬧ 无需冗长的销售流程。⬧ 无需合同。⬧ 100% 退款保证。

无妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,以确保您的 Web 应用程序取得成功。

Kirschbaum
Shift logo

Shift

正在运行旧版本的 Laravel?即时、自动的 Laravel 升级和代码现代化,让您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

仅需每月 2,500 美元,即可使用经验丰富的 Laravel 开发人员(拥有 4-6 年经验)为您的项目增效。获得 160 小时的专用专业知识和 15 天的无风险试用。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

立即获得幸运 - Laravel 开发的理想选择,拥有超过十年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar:Laravel 电子商务

Laravel 的电子商务。一个开源软件包,它将现代无头电子商务功能的强大功能带入 Laravel。

Lunar:Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 工作板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit:Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,它包含运行现代 SaaS 所需的所有功能。付款、漂亮结账、管理面板、用户仪表板、身份验证、就绪组件、统计、博客、文档等等。

SaaSykit:Laravel SaaS 启动工具包
Rector logo

Rector

您无缝升级 Laravel、降低成本和加速创新以帮助企业取得成功的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成来增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务性、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解 MongoDB 的灵活的现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel Prompts 构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包为您的 Laravel 应用程序添加评论

阅读文章