Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
Symfony2 : How to easily implement a REST API with oAuth2 (for normal guys)

It's still a work in progress...

Intro

As William Durand was recently explaining in his SOS, he "didn't see any other interesting blog post about REST with Symfony recently unfortunately". After spending some long hours to implement an API strongly secured with oAuth, I thought it was time for me to purpose my simple explanation of how to do it.

Ok, you know the bundles

You might have already seen some good explanation of how to easily create a REST API with Symfony2. There are famous really good bundles a.k.a. :

You've asked for a project example?

This Gist was made at my previous job working on a private repo, and I'm finally reusing this Gist for an open source project: the goal is to provide an API to get the church next to you. And there is also a React Native app in preparation.

So, here you are for the example. At the time I'm writing the oAuth2 implementation is still in the pull request, but will soon be merged. You'll have a working example to copy and test (I'll just have to improve the project Readme). And I'll also add the front views to create an account and ask for an API key.

We have a situation here

We will imagine two Symfony projects :

As our users will try to connect to our front, we want a login process à la Facebook, which you will see, is the oAuth grant_type authorization_code process.

The front is an oauth_client who try to connect to the back. This oauth_client is created with a command line on the back. You then retrieve an id and a secret.

Warning If you look into the database to get the id, it's the concatenation of the oauth_client.id and oauth_client.random_id, separated with an underscore. Something looking like 1_kj2gjhlice8wkoxwggpok80hk0wcewkwfkk4c4wocawwgc0ko.

You need to learn a bit of oAuth2

You need to understand that there are different "ways" to "connect" with oAuth2 and retrieve an access_token that you will use to hit your API. They are well explained in this Tankist blog post (read them all, they are just great).

Whatever the way you use to retrieve the access_token, you want to get something like this :

{
    access_token: "NGM3NDI2OGQ0MTRjMjhkYzY5ZGQ1YjViODhmYzNlZmRiNGI3YjIxN2IxZDcxY2ZjMDI3MmY3NjI2N2ZhODJjYQ"
    expires_in: 3600
    token_type: "bearer"
    scope: null
    refresh_token: "MjQyNTM0NjBiMmZlYjY3MGM2OGJmMDllZjE0ZjNhYTMxZmIyN2ZmMGRlOGJlOGUwYjRkZmJkMWU4NmY5NDVlYQ"
}

These ways are defined by a grant_type that you set to an oauth_client (multiple grant_type is possible) (it might be specific to FOSOAuthServerBundle, but I presume you will not use something else) :

grant_type=authorization_code

The "usual" process you have with Facebook : login, authorize app, redirection. So the user want to connect to your front. Simplified, here is what's happening :

  1. The front try to get the login form from the back, with its oauth_client id.
  2. The user put its credentials in the form, and if it's valid, can allow the "app" (which is the oauth_client, i.e. the front) to access the back.
  3. The user is then redirected to the front, with a nice cookie (access_token) that allow the front to request the back API.

No example here, we will come back on that process later.

grant_type=password

You still want an access_token but you get it in one request, by sending everything you have : oauth_client id and secret, and user credentials.

your_back/oauth/v2/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=password&username=USERNAME&password=PASSWORD

The process is of course simpler, but your front is storing the oauth_client secret. It might be ok because our front is in PHP, but if it's one day in Javascript, it might not be good. Also the process is not as cool as the real "Facebook/Google/GitHub" one :)

grant_type=client_credentials

Simplest request, no user credential, you only send oauth_client id and secret :

your_back/oauth/v2/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=client_credentials

This might be usefull when your back is requesting another of your API. User credential might not be needed.

grant_type=refresh_token

This one is to refresh your access_token. As your token will expire in one hour, you can ask to refresh it :

your_back/oauth/v2/token?client_id=CLIENT_ID&client_secret=CLIENT_SECRET&grant_type=refresh_token&refresh_token=REFRESH_TOKEN

As you need to have the oauth_client secret, this is not usable between our front and back, where grant_type=authorization_code will be used.

Bonus RFC-6749

I found it was the clearest explanation of the authorization_code :

The authorization code is obtained by using an authorization server as an intermediary between the client and resource owner. Instead of requesting authorization directly from the resource owner, the client directs the resource owner to an authorization server, which in turn directs the resource owner back to the client with the authorization code.

Before directing the resource owner back to the client with the authorization code, the authorization server authenticates the resource owner and obtains authorization. Because the resource owner only authenticates with the authorization server, the resource owner's credentials are never shared with the client.

The authorization code provides a few important security benefits, such as the ability to authenticate the client, as well as the transmission of the access token directly to the client without passing it through the resource owner's user-agent and potentially exposing it to others, including the resource owner.

composer.json

{
    "jms/serializer-bundle": "dev-master",
    "friendsofsymfony/user-bundle": "2.0.*@dev",
    "friendsofsymfony/rest-bundle": "1.4.*@dev",
    "friendsofsymfony/oauth-server-bundle": "1.4.*@dev",
    "nelmio/api-doc-bundle": "2.5.*@dev",
}

app/AppKernel.php

$bundles = array(
    // ...
    new JMS\SerializerBundle\JMSSerializerBundle(),
    new FOS\UserBundle\FOSUserBundle(),
    new FOS\RestBundle\FOSRestBundle(),
    new FOS\OAuthServerBundle\FOSOAuthServerBundle(),
    new Nelmio\ApiDocBundle\NelmioApiDocBundle(),
    // ...
);

app/config/config.yml

framework:
    # ...
    translator:      { fallback: "%locale%" }
    # ...

fos_user:
    db_driver: orm
    firewall_name: main
    user_class: test\ApiBundle\Entity\User

fos_oauth_server:
    db_driver: orm
    client_class:        test\ApiBundle\Entity\Client
    access_token_class:  test\ApiBundle\Entity\AccessToken
    refresh_token_class: test\ApiBundle\Entity\RefreshToken
    auth_code_class:     test\ApiBundle\Entity\AuthCode
    service:
        options:
            supported_scopes: read

nelmio_api_doc: ~

sensio_framework_extra:
    view:
        annotations: false

fos_rest:
    param_fetcher_listener: true
    body_listener: true
    format_listener: true
    view:
        view_response_listener: 'force'
    routing_loader:
        default_format: json
    access_denied_listener:
        json: true
    exception:
        codes:
            'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
            'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
        messages:
            'Symfony\Component\Routing\Exception\ResourceNotFoundException': true

app/config/routing.yml

# FOSUserBundle
fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"

fos_user_profile:
    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
    prefix: /profile

fos_user_register:
    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
    prefix: /register

fos_user_resetting:
    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
    prefix: /resetting

fos_user_change_password:
    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
    prefix: /profile

# FOSAuthServerBundle
fos_oauth_server_token:
    resource: "@FOSOAuthServerBundle/Resources/config/routing/token.xml"

fos_oauth_server_authorize:
    resource: "@FOSOAuthServerBundle/Resources/config/routing/authorize.xml"

# testApiBundle
test_api_bundle:
    type: rest
    resource: "@testApiBundle/Resources/config/routing.yml"
    prefix:   /

app/config/security.yml

Please remember we've put a context name at test_connect, we'll use it soon !

security:
    encoders:
        vp\GlobalBundle\Entity\User:
            algorithm:            pbkdf2
            hash_algorithm:       sha512
            encode_as_base64:     true
            iterations:           1000

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        user_provider:
            id: vp_global_user_provider

    firewalls:
        oauth_token:
            pattern:    ^/oauth/v2/token
            security:   false

        oauth_authorize:
            pattern:    ^/oauth/v2/auth
            form_login:
                provider: user_provider
                check_path: vp_global_login_check
                login_path: vp_global_login
            anonymous: true
            context: test_connect

        api:
            pattern:    ^/
            fos_oauth:  true
            stateless:  true
            anonymous:  true # Needed to allow access to oauth pages

    access_control:
        - { path: ^/oauth/v2/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }
<?php
namespace test\ApiBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class ClientCreateCommand extends Command
{
protected function configure()
{
$this
->setName('vp:oauth-server:client-create')
->setDescription('Create a new client')
->addArgument('name', InputArgument::REQUIRED, 'Sets the client name', null)
->addOption('redirect-uri', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Sets redirect uri for client. Use this option multiple times to set multiple redirect URIs.', null)
->addOption('grant-type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Sets allowed grant type for client. Use this option multiple times to set multiple grant types.', null)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$clientManager = $this->getApplication()->getKernel()->getContainer()->get('fos_oauth_server.client_manager.default');
$client = $clientManager->createClient();
$client->setName($input->getArgument('name'));
$client->setRedirectUris($input->getOption('redirect-uri'));
$client->setAllowedGrantTypes($input->getOption('grant-type'));
$clientManager->updateClient($client);
$output->writeln(sprintf('Added a new client with name <info>%s</info> and public id <info>%s</info>.', $client->getName(), $client->getPublicId()));
}
}

composer.json

{
    "hwi/oauth-bundle": "0.4.*@dev",
    "guzzle/guzzle": "3.8.*@dev",
}

app/AppKernel.php

$bundles = array(
    // ...
    new HWI\Bundle\OAuthBundle\HWIOAuthBundle(),
    // ...
);

app/config/config.yml

hwi_oauth:
    firewall_name: oauth2_secured_api
    resource_owners:
        test_connect:
            type:                oauth2
            client_id:           %oauth_client%
            client_secret:       %oauth_secret%
            access_token_url:    %website_back_base_url%/oauth/v2/token
            authorization_url:   %website_back_base_url%/oauth/v2/auth
            infos_url:           %website_back_base_url%/me
            scope:               "read"
            user_response_class: HWI\Bundle\OAuthBundle\OAuth\Response\PathUserResponse
            paths:
                identifier: id
                nickname:   username
                realname:   username

app/config/routing.yml

hwi_oauth_redirect:
    resource: "@HWIOAuthBundle/Resources/config/routing/redirect.xml"
    prefix:   /connect

hwi_oauth_login:
    resource: "@HWIOAuthBundle/Resources/config/routing/login.xml"
    prefix:   /login

app/config/security.yml

Look at that ! the same context: test_connect so the two firewalls can talk to each other !

security:
    encoders:
        Symfony\Component\Security\Core\User\User: plaintext

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    providers:
        hwi:
            id: hwi_oauth.user.provider

    firewalls:
        dev:
            pattern:  ^/(_(profiler|wdt)|css|images|js)/
            security: false

        oauth2_secured_api:
            anonymous: ~
            context: test_connect
            oauth:
                resource_owners:
                    test_connect: "/login/test-connect"
                login_path:        /login
                use_forward:       false
                failure_path:      /login
                oauth_user_provider:
                    service: hwi_oauth.user.provider

    access_control:
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/me, roles: ROLE_USER }

Hi, do you have a working project to share? would really help!

Just like @zwaldeck mentioned, this would be super!

dtraore commented Apr 8, 2015

great tuto but how to manage the logout from API

I imagine you would need to delete the access_token from your DB that stores the tokens and of course also delete it from any cookies / local storage. After that the client won't be logged in any longer...

Hi, I didn't find the property $name in Client Entity. I suppose it is an alias for random_id, is your personal customization?
I'm talking about the line 29 in ClientCreateCommand: $client->setName($input->getArgument('name'));

Hi,Thanks for this article,
can anyone please tell why there is a firewall and a security implementation in the front, i'm a little bit lost...

Because even though you have moved the logic of the security (authentication & authorization) to the api, your application still needs to work with the flow of security layers. In other words it needs to know when to apply the authentication and authorization.
For example, following the config above: inside the client the user would like to view his/her user information at /me. When the user navigates to /me the client application will see in its security config that that route is secured by access control. To check if the user is allowed to go there it will have to check with the firewall. The code of the oauth2_secured_api firewall will then check with the backend server if this is allowed and when the user is not authenticated will request authentication credentials from the client which in turn might prompt the user for credentials (depending on implementation).
So basicly the security layer of the client is just a dumb layer which only knows WHEN it needs to request authentication & authorization, actually knowledge of who to allow or not is done in the backend.

Hi ! thanks for the great tuto !

If you want to use
fos_user:
db_driver: orm
firewall_name: main
user_class: test\ApiBundle\Entity\User

you will need a firewall named 'main' in security.yml

Nelmio need his route in routing.yml

NelmioApiDocBundle:
resource: "/config/routing.yml"
prefix: /doc

Great Tuto, that's exactly the kind of structure I had in mind!
Thanks

barden42 commented Jun 23, 2016

Hi,

Thank you for your tuto. It work for me on the API side, but I don't know how to connect my front office.
I would like to create a login page on this front office and connect my user through the API (and get the authentication token for future request).
Can you explain it please ?

I think what I'm trying to accomplish is very similar, but I'm struggle figuring out the whole thing.

I am building an API. For the sake of this example, let's say GET /api/properties requires authentication.
I want users to authenticate (POST /login) before calling /api/properties.
I want to offer the ability to authenticate with their google/facebook account.

Is this what the code above will do?

david-vde commented Aug 15, 2016

Hello

I need some help please. You ask to create entities but I don't see the fields details? Witch fields name have we to configure in the entities?
It's not so clear.

Thanks

Owner

lologhi commented Oct 25, 2016

Sorry, Github don't send any alert when somebody comment on a Gist (isaacs/github#21). I've added https://giscus.co and should be notified of future comments!

vooxo commented Mar 5, 2017

Thanks for the article!

amphee commented Mar 14, 2017

grant_type=authorization_code

The "usual" process you have with Facebook : login, authorize app, redirection. So the user want to connect to your front. Simplified, here is what's happening :

The front try to get the login form from the back, with its oauth_client id.
The user put its credentials in the form, and if it's valid, can allow the "app" (which is the oauth_client, i.e. the front) to access the back.
The user is then redirected to the front, with a nice cookie (access_token) that allow the front to request the back API.
No example here, we will come back on that process later.

===> Anyone having this example? I have the same situation to retrieve access token when I do facebook login.

Owner

lologhi commented Mar 15, 2017

Indeed, no example here @amphee , sorry! But you got the logic, so please share what you'll build ;-)

They are well explained in this Tankist blog post (read them all, they are just great).

Not accessible anymore.

Owner

lologhi commented Nov 20, 2017

Thank you @ionafan2, it's updated!

Hello,

Could you explain this part please :
form_login:
provider: user_provider
check_path: vp_global_login_check
login_path: vp_global_login

whats vp_global_login ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment