Creating a Lumen API with OAuth for Google Authentication

Lumen is the younger brother of Laravel. It is supposed to be faster and it is especially designed for building APIs. With this project, we aim to create a JSON API which will also provide OAuth support, mainly for using the visitor’s Google account for authentication.

Decisions, assumptions and hints

  • The app might be used on shared hosting as well;
  • The lumen version is 5.8.*;
  • The root of the project is ./app – for commands and paths, we assume we have it as root;
  • The prefix for our app is lao (from “Lumen API OAuth”);
  • nano is a code editor and cat is a read and write utility, both specific to unix like systems;
  • Please note that some commands (e.g. composer) are available inside the container.

Setup the base of the project

As structure, we will separate docker from the lumen source code. Depending on the project you are working on, the needs might vary. We will also add some helper files.

Some helper files are the .gitignore files. Other such file is .editorconfig, which is a great help for keeping the code in order, regardless of the operating system where it is edited or the code editor used:

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.php]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4


[*.sh]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

./src contains the lumen source code.

./docker contains the docker and docker-compose related code. This is intended to be used for development purposes, thus the configuration is specified inside the docker-compose.yml file.

A repo with the code is available at: https://gitlab.com/cristidraghici/lumen-api-with-oauth-support.

Docker and docker-compose

Create a basic ./docker/docker-compose.yml file:

version: '2'
services:
  lao-lumen:
    container_name: lao-lumen
    build:
      context: ./lumen
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "9000:80"
    volumes:
      - ./../src:/var/www:cached
      - ./lumen/.data/logs:/var/log/nginx:delegated
    links:
      - lao-database
    depends_on:
      - lao-database

  lao-database:
    container_name: lao-database
    build:
      context: ./database
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "33066:3306"
    environment:
      - MYSQL_DATABASE=homestead
      - MYSQL_USER=homestead
      - MYSQL_PASSWORD=secret
      - MYSQL_ROOT_PASSWORD=secret
    volumes:
      - ./database/.data/mysql:/var/lib/mysql:delegated

networks:
  default:
    external:
      name: "lao-network"

A special mention about the .yml content above: :delegated and :cached help with performance. More information here:
https://docs.docker.com/docker-for-mac/osxfs-caching/

Lumen

Due to the fact that there are special requirements for the application, we will define a ./docker/lumen/Dockerfile for it:

FROM php:7.1-fpm-jessie

RUN docker-php-ext-install pdo_mysql bcmath mbstring sockets exif \
  && apt-get update \
  && apt-get install zip unzip

# Install PHP Composer
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
  && php composer-setup.php \
  && rm composer-setup.php \
  && mv composer.phar /usr/local/bin/composer

RUN pecl install xdebug && \
   docker-php-ext-enable xdebug

RUN echo "deb http://nginx.org/packages/debian/ jessie nginx" >> /etc/apt/sources.list \
  && echo "deb-src http://nginx.org/packages/debian/ jessie nginx" >> /etc/apt/sources.list \
  && curl http://nginx.org/keys/nginx_signing.key | apt-key add - \
  && apt-get update \
  && apt-get install -y nginx \
  && apt-get install -y libpng-dev libjpeg-dev libwebp-dev \
  && apt-get install -y libav-tools \
  && rm -rf /var/lib/apt/lists/*

# Forward request and error logs to docker log collector
RUN ln -sf /dev/stdout /var/log/syslog

WORKDIR /var/www/

COPY ./nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

COPY ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

The configuration for nginx will be stored in ./docker/lumen/nginx.conf:

user  root;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
  worker_connections  1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  client_body_buffer_size 10M;
  client_header_buffer_size 10K;
  client_max_body_size 10M;
  large_client_header_buffers 2 1K;

  sendfile        on;
  #tcp_nopush     on;

  keepalive_timeout  65;

  gzip  on;

  upstream php {
    server 127.0.0.1:9000;
  }

  server {
    listen 80;
    listen [::]:80 default_server ipv6only=on;

    root /var/www/public/;
    index index.php;

    # Make site accessible from http://localhost/
    server_name _;

    location / {
      try_files $uri $uri/ /index.php?$query_string;
    }

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    location ~ \.php$ {
      try_files $uri /index.php =404;
      fastcgi_split_path_info ^(.+\.php)(/.+)$;
      fastcgi_pass php;
      fastcgi_index index.php;
      fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
      include fastcgi_params;
    }

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    location ~ /\.ht {
      deny all;
    }
  }
}

The ./docker/lumen/entrypoint.sh file will be run when the container starts:

#!/bin/bash

PIDS=();
nginx -g "daemon off;" & PIDS+=($!);
php-fpm & PIDS+=($!);

FLAG=''
trap '
  if [ ! "$FLAG" ]; then
    FLAG=true
    ((${#PIDS[@]})) && kill "${PIDS[@]}"
  fi
' CHLD

set -m;
wait;
set +m

Database

We use mariadb as database. For the sake of consistency, the Dockerfile is inside a dedicated folder, ./docker/database/Dockerfile:

FROM mariadb:10.4.11-bionic

Start the project

In order to execute commands inside the container, the containers need to be started. To do that, the following commands are necessary:

  • cd ./docker
  • docker network create lao-network
  • docker-compose up --build -d

lumen setup

Please note that the docker-compose.yml we defined earlier contains the default mysql settings defined by laravel/lumen. We will setup the lumen project inside the dedicated container – this way we will avoid installing composer on the host machine and also have a consistent version for it in all the develoment envs.

Commands to run:

  • docker exec -it lao-lumen bash
  • composer create-project --prefer-dist laravel/lumen .
  • cp .env.example .env
  • update .env with a random value for APP_KEY, which can be generated with:
    ./cli.sh exec date | md5
  • update .env with http://localhost:9000 for APP_URL;
  • update .env with database (the name of the container set in the docker-compose.yml file) for DB_HOST.

cli.sh

We aim to simplify our tasks as much as possible. Thus, command line helpers are always handy. In this project, the ./cli.sh will offer the following options:

  • cli.sh start
  • cli.sh stop
  • cli.sh build
    Will start the project, but also rebuild the containers;
  • cli.sh exec <command>
    This will execute a command on the lumen container. A quite useful value for <command> could be bash

It will also create the docker network automatically, if needed.

#!/bin/bash

case "$OSTYPE" in
  darwin*)  OS="darwin" ;;
  linux*)   OS="linux" ;;
  *)        echo "unknown: $OSTYPE"; exit ;;
esac

function usage {
  echo ""
  echo "The parameters to use with this tool are the following: "
  echo ""
  echo "  start"
  echo "  stop"
  echo "  build"
  echo "  exec <command> - execute command in lao-lummen"
  echo ""
  echo "e.g. ./cli.sh exec bash"
  echo "     ./cli.sh exec composer install"
  echo ""
}

if [ $# -eq 0 ]; then
    usage;
    exit 1
fi

[ ! "$(docker network list | grep lao-network)" ] && docker network create lao-network


case $1 in
  build)
    (cd docker && docker-compose down && docker-compose -f ./docker-compose.yml up -d --build --force-recreate)
    ;;
  start)
    (cd docker && docker-compose -f ./docker-compose.yml up -d)
    ;;
  stop)
    (cd docker && docker-compose down)
    ;;
  exec)
    shift;
    docker exec -it lao-lumen $@;
    ;;
  *)
    usage;
    ;;
esac

Working on the API

With the basic setup done, the following steps will turn the basic lumen project into a reliable JSON api.

We aim to create an API following the guidelines found at:
https://jsonapi.org/

A note on debugging

As any respectable framework / microframework, Lumen has very good error logging using Monolog. You will find detail on errors by checking the folder: ./src/storage/logs/.

Always return a JSON

Edit ./src/app/Exceptios/Handler.php and replace the render() function with:

public function render($request, Exception $e)
{
    $parentRender = parent::render($request, $e);

    if ($parentRender instanceof JsonResponse)
    {
        return $parentRender;
    }

    return new JsonResponse([
        'errors' => [
            [
                'status' => $parentRender->getStatusCode(),
                'title' => $e instanceof HttpException
                    ? $e->getMessage()
                    : 'Server Error',
            ]
        ],
        'status' => false
    ], $parentRender->status());
}

At the top of the file, use Illuminate\Http\JsonResponse; is needed. Otherwise, you need to replace every occurrence of JsonResponse with \Illuminate\Http\JsonResponse.

Also, the main route should return a JSON. As a general idea, you should return as little information as possible about the package versions you are using in your application or information about your server, to prevent unnecessary exposure. But for now, just edit the main route in ./src/app/routes/web.php to:

$router->get('/', function () use ($router) {
    return [
        'data' => $router->app->version(),
        'status' => true
    ];
});

More here:
https://stackoverflow.com/questions/37296654/laravel-lumen-ensure-json-response

Add lumen-passport

Now, connect to the container and install lumen-passport:

./cli.sh exec composer require dusterio/lumen-passport

Create a migration for the users table

Create a users migration, using our cli tool (./cli.sh exec php artisan make:migration create_users_table) with the following content:

Schema::create('users', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('name');
    $table->string('email');
    $table->string('password');
    $table->timestamp('last_logged_in')->nullable();
    $table->timestamps();
 });

Create ./src/app/Http/Controllers/UsersController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\User;
use Validator;

class UsersController extends Controller
{
    public function register (Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required',
            'email' => 'required|email',
            'password' => 'required'
        ]);

        if ($validator->fails()) {
            return response([
                'message' => 'Validation errors',
                'errors' =>  $validator->errors(),
                'status' => false
            ], 422);
        }

        $input = $request->all();
        $input['password'] = Hash::make($input['password']);
        $user = User::create($input);

        $data['token'] = $user->createToken('app')->accessToken;
        $data['name'] = $user->name;
        $data['email'] = $user->email;

        return response([
            'data' => $data,
            'message' => 'Account created successfully!',
            'status' => true
        ]);
    }
}

Alter ./src/app/User.php

<?php

namespace App;

use Illuminate\Auth\Authenticatable;
use Laravel\Lumen\Auth\Authorizable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Laravel\Passport\HasApiTokens;

class User extends Model implements AuthenticatableContract, AuthorizableContract
{
    use HasApiTokens, Authenticatable, Authorizable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes excluded from the model's JSON form.
     *
     * @var array
     */
    protected $hidden = [
        'password'
    ];
}

Create ./src/app/config/auth.php

<?php

return [
    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],

    'guards' => [
        'api' => [
            'driver' => 'passport',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => \App\User::class
        ]
    ]
];

Alter ./src/bootstrap/app.php

Uncomment the following two lines:

  • $app->withFacades();
  • $app->withEloquent();

And before $app->router->group([... add:

// Load auth config files
$app->configure('auth');

// Enable auth middleware (shipped with Lumen)
$app->routeMiddleware([
    'auth' => App\Http\Middleware\Authenticate::class,
    'client' => CheckClientCredentials::class,
]);

// Finally register two service providers - original one and Lumen adapter
$app->register(Laravel\Passport\PassportServiceProvider::class);
$app->register(Dusterio\LumenPassport\PassportServiceProvider::class);

// Register the routes
\Dusterio\LumenPassport\LumenPassport::routes($app, ['prefix' => 'v1/oauth']);

Alter ./src/routes/web.php

<?php

/*
|--------------------------------------------------------------------------
| Application Routes
|--------------------------------------------------------------------------
|
| Here is where you can register all of the routes for an application.
| It is a breeze. Simply tell Lumen the URIs it should respond to
| and give it the Closure to call when that URI is requested.
|
*/

$router->get('/', function () use ($router) {
    return 'Hello, there! :)';
});

$router->get('/api', function () use ($router) {
    return [
        'data' => $router->app->version(),
        'status' => true
    ];
});

$router->post('/api/v1/register','UsersController@register');

Migrations and passport

The following commands will be executed inside the container and will handle running the migrations for the database structure and also install passport:

  • ./cli.sh exec php artisan migrate
  • ./cli.sh exec php artisan passport:install
  • ./cli.sh exec php artisan passport:client --personal

More information here: