A MVP for API Authentication of Type HTTP Basic

Introduction

In this post I will present a very simple RESTful service API, written in Symfony & Doctrine, SQLite will be the database, POSTMAN is our favorite client.

It shall demonstrate…

  • How to log in using the Basic Auth (cleartext username & password) via POSTMAN.
  • How to generate test data with Symfony fixtures, including encrypted passwords.
  • How to change security.yaml, so the security settings are properly set & activated.
  • How to write a simple controller, which will
    • Allow to show / for anonymous users
    • Allow to show /admin for ROLE_ADMIN
    • Allow to show /user for ROLE_USER
    • Deny everything else.

Given we use a server on basic-auth.internal on port 8091, an API could look like this:

GET basic-auth.internal:8091/ #anon.
GET basic-auth.internal:8091/admin #ROLE_ADMIN
GET basic-auth.internal:8091/user  #ROLE_USER

Grosso mode, this guide follows https://symfony.com/doc/current/security.html

About the HTTP Basic Authentication

It seems rather unsafe and outdated to use HTTP Basic, but it is safe, when used in a HTTPS context, plus it requires the user ID/password pair to be sent every single request, which is rather nice when it comes to the statelessness of our API.

Thus, to make it clear:

Use Basic Auth only within a HTTPS environment!

Ad Fontes

Create the environment

Pre-requisites: a working composer installation, a working apache server with virtual hosts configured as seen above.

cd /Library/WebServer/Documents/symf
composer create-project symfony/website-skeleton basic-auth
cd basic-auth

We now should change the .env file so it points to the sqlite file instead of a MySQL installation. So open the file, change the DATABASE_URL line to the following line:

DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db

After this has changed, we can create the database and the log entity (for the sake of simplicity we give everybody all rights to access the file (this is not a good idea in production ideas…):

php bin/console doctrine:database:create
chmod 77777 var/data.db

Create the User Entity

Before creating the User entity please make sure your Symfony installation contains the security bundle:

composer require symfony/security-bundle

After this has been installed, create the entity:

$php bin/console make:user

The name of the security user class (e.g. User) [User]:
> User

Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes

Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid [email]
> email

Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes

created: src/Entity/User.php
created: src/Repository/UserRepository.php
updated: src/Entity/User.php
updated: config/packages/security.yaml

$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate

Please check that we now have a changed security.yaml:

security:
     encoders:
         App\Entity\User:
             algorithm: bcrypt
     providers:
         app_user_provider:
             entity:
                 class: App\Entity\User
                 property: email

This file now contains the hint to the encryption algorithm (bcrypt), plus the link to the User entity.

Fixtures

Fixtures are pre-set test data. Thus with fixtures we could generate some users in advance. What we want to do is to generate some users of role ROLE_USER and at least one ROLE_ADMIN user. The bundle should be there already:

composer require --dev doctrine/doctrine-fixtures-bundle

To use fixtures we need to create the respective classes:

$php bin/console make:fixtures
 The class name of the fixtures to create (e.g. AppFixtures):
 > UserFixtures

After this has been created, open the file in the editor and create some test users:

<?php
 namespace App\DataFixtures;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Common\Persistence\ObjectManager;
 use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
 use App\Entity\User;
 
class UserFixtures extends Fixture {
 private $passwordEncoder;
 public function __construct(UserPasswordEncoderInterface $passwordEncoder) {
 $this->passwordEncoder = $passwordEncoder;
 }
      public function load(ObjectManager $em) {
      $test_users = [
       'jack@logger.one' => ['password' => 'jack', 
             'roles' => ['ROLE_ADMIN', 'ROLE_USER']], 
      'john@logger.one' => ['password' => 'john', 
             'roles' => ['ROLE_USER']], 
      'gil@logger.one' => ['password' => 'gil', 
             'roles' => ['ROLE_USER']], 
      'jenny@logger.one' => ['password' => 'jenny', 
             'roles' => ['ROLE_USER']]
      ];
      foreach ($test_users as $email => $userData) {
      $user = new User();
      $user->setEmail($email);
      $user->setRoles($userData['roles']);
      $user->setPassword(
          $this->passwordEncoder->encodePassword(
                   $user, $userData['password']
      ));
      $em->persist($user);
         $em->flush();
      }
      }
 }

These test uses can be injected into the database. Since the password is encoded correctly, the raw database will show it encrypted. To insert the data into the database, call (be ware that the database will be purged…):

$php bin/console doctrine:fixtures:load

Within DB Browser, your “user” table now should look a lot like this:

We now have a running user management, but no controller. Let’s create the bare minimum with static JSON return strings:

<?php
 
 namespace App\Controller;

 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
 
 class LogController extends AbstractController {
     /**
      * @Route("/", name="index", methods={"GET"})
     */
     public function index() {
        return JsonResponse::fromJsonString(
           "{'message': '/'}");
     }
 

     /**
      * @Route("/admin", name="admin", methods={"GET"})
      * @IsGranted("ROLE_ADMIN")
     */
     public function admin() {
        return JsonResponse::fromJsonString(
           "{'message': '/admin'}");
     }
 

     /**
      * @Route("/user", name="user", methods={"GET"})
      * @IsGranted("ROLE_USER")
     */
     public function user() {
        return JsonResponse::fromJsonString(
           "{'message': '/user'}");
     }
 

 }

The interesting bit: We are almost done. All we have to do is to allow anonymous access and make http_basic security mandatory for a realm called secured area. BTW: The stateless hint gives us the opportunity to switch off session caching.

    firewalls:
         main:
             anonymous: true
             http_basic:
                 realm: Secured Area
             stateless: true
 

Now got to POSTMAN and try http://basic-auth.internal:8091/. This should route the the index function, accessible by everybody. HTTP status: 200 OK.

On the other hand, http://basic-auth.internal:8091/admin should lead to an 401 Unauthorized error, since we did not enter any login data. So let’s do it. Switch to the authorization tab, set the type to Basic Auth, and enter username “jack@logger.one” and password “jack”, save and send. You should get a HTTP status 200 OK and within the Body tab the clear message, that you have accessed the admin() function. The test of the /user route I leave to my reader.