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 at the time this article was created 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.
  • Another assumption is that we already have the google client setup: values for GOOGLE_CLIENT_ID , GOOGLE_CLIENT_SECRET and GOOGLE_REDIRECT_URI, later mentioned in this article.
  • Please note that this article has been partially updated later, thus some packages used might have been created later than the time this article was initially published. 🙂

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 = falseCode language: JavaScript (javascript)

./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"Code language: JavaScript (javascript)

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

<em># Install PHP Composer</em>
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/*

<em># Forward request and error logs to docker log collector</em>
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"]Code language: PHP (php)

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;
  <em>#tcp_nopush     on;</em>

  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;

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

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

    <em># pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000</em>
    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;
    }

    <em># deny access to .htaccess files, if Apache's document root</em>
    <em># concurs with nginx's one</em>
    location ~ /\.ht {
      deny all;
    }
  }
}Code language: PHP (php)

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

<em>#!/bin/bash</em>

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 +mCode language: PHP (php)

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-bionicCode language: CSS (css)

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.

<em>#!/bin/bash</em>

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;
    ;;
esacCode language: PHP (php)

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());
}Code language: PHP (php)

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
    ];
});Code language: PHP (php)

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

Enable facades and eloquent

Edit ./src/bootstrap/app.php and uncomment the following two lines:

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

Use Socialite to authenticate with Google

Socialite is a laravel package meant to be used for authenticating your application with OAuth2 against services like Google, Facebook and many others. Since lumen is a stripped of version of laravel, we can use this package in our project.

More information:

Add the package to the project with the following command:

./cli.sh exec composer require laravel/socialite

For newer lumen versions you can also add flipbox/lumen-generator "9.*" which will bring more cli commands to the project. This is optional and is slightly against the idea of lumen itself, which is supposed to be, by design, smaller and simpler than laravel.

Add the needed environment values

Edit the .env.example file and add the following empty configuration strings for the google authentication configuration:

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=

Of course, the above must also be added with values in the .env file of your project.

Then, create ./src/app/config/services.php with the following content:

<?php

return [
    'google' => [
        'client_id' => env('GOOGLE_CLIENT_ID'),
        'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'redirect' => env('GOOGLE_REDIRECT_URI'),
    ],
];Code language: HTML, XML (xml)

And finally, edit ./src/bootstrap/app.php and add:

<em>// Load services config files</em>
$app->configure('services');Code language: PHP (php)

Create the migrations

Create a users migration, using our cli tool:

<code>./cli.sh exec php artisan make:migration create_users_table</code>Code language: HTML, XML (xml)

Then add 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();
 });Code language: PHP (php)

After the users table is created, we need to add the providers table:

<code>./cli.sh exec php artisan make:migration create_providers_table</code>Code language: HTML, XML (xml)

For it, add the following content:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('providers', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('provider');
            $table->string('provider_id');
            $table->bigInteger('user_id')->unsigned();
            $table->string('avatar')->nullable();
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('providers');
    }
};Code language: HTML, XML (xml)

Once the files are updated, run: ./cli.sh exec php artisan migrate

Alter ./src/app/User.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;


class User extends Model
{
    use HasFactory;

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

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];


    public function providers()
    {
        return $this->hasMany(Provider::class,'user_id','id');
    }
}Code language: HTML, XML (xml)

Create ./src/app/Provider.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Provider extends Model
{
    use HasFactory;

    protected $fillable = ['provider','provider_id','user_id','avatar'];
    protected $hidden = ['created_at','updated_at'];
}
Code language: HTML, XML (xml)

Create the ./src/app/Http/Controllers/AuthController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Http\JsonResponse;
use Laravel\Socialite\Facades\Socialite;
use App\User;

class AuthController extends Controller
{


    /**
     * Redirect the user to the Provider authentication page.
     *
     * @param $provider
     * @return JsonResponse
     */
    public function redirectToProvider($provider)
    {
        $validated = $this->validateProvider($provider);
        if (!is_null($validated)) {
            return $validated;
        }

        return Socialite::driver($provider)->stateless()->redirect();
    }

    /**
     * Obtain the user information from Provider.
     *
     * @param $provider
     * @return JsonResponse
     */
    public function handleProviderCallback($provider)
    {
        $validated = $this->validateProvider($provider);
        if (!is_null($validated)) {
            return $validated;
        }

        try {
            $user = Socialite::driver($provider)->stateless()->user();
        } catch (ClientException $exception) {
            return response()->json(['error' => 'Invalid credentials provided.'], 422);
        }

        $userCreated = User::firstOrCreate(
            [
                'email' => $user->getEmail()
            ],
            [
                'email_verified_at' => \Carbon\Carbon::now(),
                'name' => $user->getName(),
                'status' => true,
            ]
        );
        $userCreated->providers()->updateOrCreate(
            [
                'provider' => $provider,
                'provider_id' => $user->getId(),
            ],
            [
                'avatar' => $user->getAvatar()
            ]
        );
        return response()->json($userCreated, 200);
    }

    /**
     * @param $provider
     * @return JsonResponse
     */
    protected function validateProvider($provider)
    {
        if (!in_array($provider, ['google'])) {
            return response()->json(['error' => 'Please login using google.'], 422);
        }
    }
}
Code language: HTML, XML (xml)

Create the routes

The following routes are to be added in ./src/routes/web.php:

// oAuth routes
$router->get('/login/{provider}', 'AuthController@redirectToProvider');
$router->get('/login/{provider}/callback', 'AuthController@handleProviderCallback');Code language: PHP (php)

You could add these routes in a special file (e.g. ./src/routes/api.php), but the the file would need to be loaded in the project bootstrapping app.php file.

Alter ./src/bootstrap/app.php

Add the following line:

$app->register(Laravel\Socialite\SocialiteServiceProvider::class);Code language: PHP (php)

Manage application tokens

Laravel offers Passport and Sanctum for managing API tokens. In one phrase: the former covers most use cases and implements OAuth2, while the latter only delivers “a much simpler API authentication development experience”, to quote the docs:

https://laravel.com/docs/8.x/passport#passport-or-sanctum

Add lumen-passport

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

<code>./cli.sh exec composer require dusterio/lumen-passport</code>Code language: HTML, XML (xml)

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
        ]);
    }
}Code language: HTML, XML (xml)

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;

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

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

Code language: HTML, XML (xml)

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
        ]
    ]
];Code language: HTML, XML (xml)

Once you have created the file, add the following line in the ./src/bootstrap/app.php file:

<em>// Load auth config files</em>
$app->configure('auth');Code language: PHP (php)

Alter ./src/bootstrap/app.php

Before $app->router->group([... add:

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

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

<em>// Register the routes</em>
\Dusterio\LumenPassport\LumenPassport::routes($app, ['prefix' => 'v1/oauth']);Code language: PHP (php)

Alter ./src/routes/web.php

Add the following line:

$router->post('/register','UsersController@register');Code language: PHP (php)

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:

And here: