Using Sanctum to authenticate a mobile app

Using Sanctum to authenticate a mobile app

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/sanctum
php 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):

Filed in: News

Newsletter

Join 31,000+ others and never miss out on new tips, tutorials, and more.

Laravel News Partners

Laravel Jobs

Senior Full-Stack Engineer
[REMOTE: USA Only] All Other Applications Will be Rejected Immediately"
Curricula
Senior Full Stack PHP Developer
Canada
Motto Design Studio
👉 Looking for Senior Back-End Laravel API Developer - Immediate (Full-time Contractor) 🎉
Remote
ApproveMe.com // The Document Signing Experience™
Senior Software Engineer
Remote or Los Angeles
Coverfly
Sr. Software Engineer
Salt Lake City, UT
Clearlink