Using Sanctum to authenticate a mobile app
Published on by Alex Pestell
Sanctum is Laravel’s lightweight API authentication package. In my last article, I looked at authenticating a React SPA with a Laravel API via Sanctum. This tutorial will go over using Laravel Sanctum to authenticate a mobile app. The app will be built in Flutter, Google’s cross-platform app development toolkit. I may skip some implementation details of the mobile app since that is not the focus of this tutorial.
Links to the final code are at the end of this article.
The backend
I’ve set up Homestead to provision a domain name, api.sanctum-mobile.test
, where my backend will be served, as well as a MySQL database.
First, create the Laravel app:
laravel new sanctum_mobile
At the time of writing, it gives me a new Laravel project (v8.6.0). As with the SPA tutorial, the API will provide a list of books, so I’ll create the same resources:
php artisan make:model Book -mr
The mr
flags create the migration and controller too. Before we mess with the migrations, let’s first install the Sanctum package, since we’ll need its migrations again.
composer require laravel/sanctumphp artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Now, create the books
migration:
Schema::create('books', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('author'); $table->timestamps();});
Next, run your app’s migrations:
php artisan migrate
If you now take a look in the database, you’ll see the Sanctum migration has created a personal_access_tokens
table, which we’ll use later when authenticating the mobile app.
Let’s update DatabaseSeeder.php
to give us some books (and a user for later):
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'),]);
Now seed the database: php artisan db:seed
. Finally, create the route and the controller action. Add this to the routes/api.php
file:
Route::get('book', [BookController::class, 'index']);
and then in the index
method of BookController
, return all the books:
return response()->json(Book::all());
After checking that the endpoint works — curl https://api.sanctum-mobile.test/api/book
— it’s time to start the mobile app.
The mobile app
For the mobile app, we’ll be using Android Studio and Flutter. Flutter allows you to create cross-platform apps that re-use the same code for Android and iPhone devices. First, follow the instructions to install Flutter and to set up Android Studio, then launch Android Studio and click “Create a new Flutter project.”
Follow the recipe in Flutter’s cookbook to fetch data from the internet to create a page that fetches a list of books from the API. A quick and easy way to expose our API to the Android Studio device is to use Homestead’s share
command:
share api.sanctum-mobile.test
The console will output an ngrok page, which will give you a URL (something like https://0c9775bd.ngrok.io
) exposing your local server to the public. (An alternative to ngrok is Beyond Code’s Expose.) So let’s create a utils/constants.dart
file to put that in:
const API_URL = 'http://191b43391926.ngrok.io';
Now, back to the Flutter cookbook. Create a file books.dart
which will contain the classes required for our book list. First, a Book
class to hold the data from the API request:
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'], ); }}
Second, a BookList
class to fetch the books and call the builder to display them:
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), ], ); }}
And last, a BookListBuilder
to display the books:
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(); } ); }}
Now we just need to modify the MyApp
class in main.dart
to load the BookList
:
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Sanctum Books', home: new Scaffold( body: BookList(), ) ); }}
Now launch this in your test device or the emulator, and you should see a list of books.
Authentication with Sanctum
Great, so we know the API is working and that we can fetch books from it. The next step is to set up authentication.
I’m going to use the provider package, and follow the guidelines in the official documentation for setting up simple state management. I want to create an authentication provider that keeps track of the logged-in status and eventually communicates with the server. Create a new file, auth.dart
. Here’s where the authentication functionality will go. For the moment, we’ll return true
so we can test the process works:
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; }}
With this provider, we can now check whether we’re authenticated and display the correct page accordingly. Modify you main
function to include the provider:
void main() { runApp( ChangeNotifierProvider( create: (BuildContext context) => AuthProvider(), child: MyApp(), ) );}
… and modify the MyApp
class to show the BookList
widget if we’re logged-in, or a LoginForm
widget otherwise:
body: Center( child: Consumer<AuthProvider>( builder: (context, auth, child) { switch (auth.isAuthenticated) { case true: return BookList(); default: return LoginForm(); } }, )),
The LoginForm
classes contain a lot of “widgety” cruft, so I’ll refer you to the GitHub repo if you’re interested in looking at it. Anyway, if you load the app in your test device, you should see a login form. Fill in a random email and password, submit the form, and you’ll see a list of books.
Ok, let’s set up the backend to handle the authentication. The docs tell us to create a route that will accept the username and password, as well as a device name, and return a token. So let’s create a route in the api.php
file:
Route::post('token', [AuthController::class, 'requestToken']);
and a controller: php artisan make:controller AuthController
. This will contain the code from the docs:
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;}
Providing the username and password are valid, this will create a token, save it in the database, and return it to the client. To get this to work, we need to add the HasApiTokens
trait to our User
model. This gives us a tokens
relationship, allowing us to create and fetch tokens for the user, and a createToken
method. The token itself is a sha256
hash of a 40-character random string: this string (unhashed) is returned to the client, which should save it to use with any future requests to the API. More precisely, the string returned to the client is composed of the token’s id, followed by a pipe character (|
), followed by the plain text (unhashed) token.
So now we have this endpoint in place, let’s update the app to use it. The login
method will now have to post the email
, password
, and device_name
to this endpoint, and if it gets a 200
response, save the token in the device’s storage. For device_name
, I’m using the device_info package to get the device’s unique ID, but in fact, this string is arbitrary.
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();}
I use the shared_preferences
package, which allows for the storage of simple key-value pairs, to save the token:
saveToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('token', token);}
So now we’ve got the app displaying the books page after a successful login. But of course, as things stand, the books are accessible with or without successful login. Try it out: curl https://api.sanctum-mobile.test/api/book
. So now let’s protect the route:
Route:::middleware('auth:sanctum')->get('book', [BookController::class, 'index']);
Login again via the app, and this time you’ll get an error: “Problem loading books”. You are successfully authenticating, but because we don’t as yet send the API token with our request to fetch the books, the API is quite rightly not sending them. As in the previous tutorial, let’s look at the Sanctum guard to see what it’s doing here:
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;}
The first condition is skipped since we aren’t using the web guard. Which leaves us with the above code. First, it only runs if the request has a “Bearer” token, i.e. if it contains an Authorization
header which starts with the string “Bearer”. If it does, it will call the findToken
method on the PersonalAccessToken
model:
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;}
The first conditional checks to see whether the pipe character is in the token and, if not, to return the first model that matches the token. I assume this is to preserve backward compatibility with versions of Sanctum before 2.3, which did not include the pipe character in the plain text token when returning it to the user. (Here is the pull request: the reason was to make the token lookup query more performant.) Anyway, assuming the pipe character is there, Sanctum grabs the model’s ID and the token itself, and checks to see if the hash matches with what is stored in the database. If it does, the model is returned.
Back in Guard
: if no token is returned, or if we’re considering expiring tokens (which we’re not in this case), return null
(in which case authentication fails). Finally:
return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken( tap($accessToken->forceFill(['last_used_at' => now()]))->save()) : null;
Check that the tokenable
model (i.e., the User
model) supports tokens (in other words, that it uses the HasApiTokens
trait). If not, return null
– authentication fails. If so, then return this:
$accessToken->tokenable->withAccessToken( tap($accessToken->forceFill(['last_used_at' => now()]))->save())
The above example uses the single-argument version of the tap
helper. This can be used to force an Eloquent method (in this case, save
) to return the model itself. Here the access token model’s last_used_at
timestamp is updated. The saved model is then passed as an argument to the User
model’s withAccessToken
method (which it gets from the HasApiTokens
trait). This is a compact way of updating the token’s last_used_at
timestamp and returning its associated User
model. Which means authentication has been successful.
So, back to the app. With this authentication in place, we need to update the app’s call to the book
endpoint to pass the token in the request’s Authorization
header. To do this, update the fetchBooks
method to grab the token from the Auth
provider, then add it to the header:
String token = await Provider.of<AuthProvider>(context, listen: false).getToken();final response = await http.get('$API_URL/book', headers: { 'Authorization': 'Bearer $token',});
Don’t forget to add a getToken
method to the AuthProvider
class:
Future<String> getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('token');}
Now try logging in again, and this time the books should be displayed.
The final code for the API and app can be found here (including functionality for logging out):
- Backend: unlikenesses/sanctum-flutter-backend
- Flutter App: unlikenesses/sanctum-flutter-app