Symfony is an amazing framework to quickly create API. It contains the tools you need to secure your API for regular use.
Sometimes, you have to increase the security by doing end-to-end data encoding. This way, you'll be sure you won't suffer from a man-in-the-middle-attack.
Some data are critical and need more security than others. It's not about being obsessed with security, it's about protecting sensitive information. I deployed this solution for a client that needed a highly secured API.
Let's take an example. You have to develop a really simple API with 2 routes :
- /public
- /secured
The route /public
is accessible by everyone, and /secured
only by clients who use your certificate.
Generate x509 certificates
First, we'll generate the HTTPS certificate. Since we're doing security here, this is the least we can do. We will use this later.
# Certificate server (for HTTPS)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout server.key -out server.crt
openssl dhparam -out dhparam.pem 2048
Then, we'll generate certificates to secure the API.
# Certificate authority
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 365 -key ca.key -out ca.crt
# Certificate signing request - You need to set the correct information (here the email) with the same that you'll use in your Symfony configuration (in my case : contact@pierrebelin.fr)
openssl genrsa -out client.key 4096
openssl req -new -key client.key -out client.csr
The certification authority (usually called CA) is a digital certificate that certifies the owner of a public key. It verifies that the client's certificate was signed by itself and not by someone else.
A CA acts as a trusted third party—trusted both by the subject (owner) of the certificate and by the party relying upon the certificate. - Wikipedia
We create a certificate signing request (called a CSR) from the client key. Applying the CA digital certificate to the CSR will create a unique certificate for the client key signed by the CA.
If you need to know more, I'll let you check the internet, there are well-written articles to better explain how it works (I can't do it here otherwise it's going to be a 30-minute article 😉 ).
# Signe the key client by the CA
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out client.crt
cat client.crt client.key > client.pem
If you filled correctly the information about your certificate client, you should get this in your console.
We can see here that I filled the common name (CN) by MyCommonName and the email address (emailAddress) by contact@pierrebelin.fr. It means that this certificate belongs to me, and only me.
For our example, this is what we need:
- One certificate signed by the CA: client.crt
- One certificate signed by another CA: notclient.crt
This way, it tests if the security does not allow every certificate.
Configure Symfony security
To test security, we create two routes to display a message to each of them. We don't need to do anything more because we are just checking the access.
#src\TestController.php (in PHP8 annotations)
class TestController extends AbstractController
{
#[Route('/public', name: 'public')]
public function checkPublicAccess(): Response
{
return $this->json([
'message' => 'This is my public access !',
]);
}
#[Route('/secured', name: 'secured')]
public function checkSecuredAccess(): Response
{
return $this->json([
'message' => 'This is my secured access !',
]);
}
}
First, you need to install: composer require symfony/security-bundle
The first step is to define the route which needs to be secured. You have to do it in your Symfony configuration.
#security.yaml
security:
providers:
client_certificate:
memory:
users:
contact@pierrebelin.fr:
roles: ROLE_USER
firewalls:
public:
pattern: ^/public
anonymous: true
secured:
pattern: ^/secured
anonymous: false
x509:
provider : client_certificate
access_control:
- { path: ^/secured, role: ROLE_USER, requires_channel: https }
This configuration :
- allows everyone to access to
/public
- adds access control on
/secured
where the client needs to be authenticated by x509 (meaning by certificates)
At this point, you can easily access to /public
but no longer to /secured
without a client certificate with the email address contact@pierrebelin.fr
.
Configure Nginx security
It's now about time to use our awesome certificates.
server {
listen 443 ssl;
listen [::]:443 ssl;
# HTTPS
ssl_certificate /PATH/TO/CERT/server.crt;
ssl_certificate_key /PATH/TO/CERT/server.key;
# x509
ssl_dhparam /PATH/TO/CERT/dhparam.pem;
ssl_protocols SSLv3 TLSv1.2 TLSv1.1 TLSv1;
root PATH;
server_name SERVERNAME;
index index.php index.html;
location / {
#try_files $uri $uri/ /index.php;
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_param HTTPS on;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; # Change if path is different
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SSL_CLIENT_I_DN $ssl_client_i_dn;
fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn; #We pass here information from the client certificate
fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify;
include fastcgi_params;
fastcgi_buffers 16 32k;
fastcgi_buffer_size 64k;
fastcgi_busy_buffers_size 64k;
}
}
At the same time, we are improving security by using HTTPS (the least we can do) and adding certificate interpretation. Nginx will now add parameters that contain information from the client's certificate.
SSL_CLIENT_S_DN
contains all client's certificate fields. x509 authentication is based on the information inside (email address, company name, common name...).
Symfony will now have everything it needs to check the client's authority!
You should now have your API with HTTPS. This is the most important thing, otherwise, you won't be able to test.
Let's test the Symfony x509 authenticator
This is the error you should have displayed when you try to access to /secured
on your browser.
It's a good start!
To validate the security, we'll create some tests and use PHPUnit.
If testing is not a common activity for you, check the Symfony documentation, you'll find everything you need.
We'll check the status code of each response. If the request returns a 200 code, we assume that the request was able to get the content. If not, the request was blocked by the authorization.
class WebTest extends WebTestCase
{
public function testGetPublicWithoutCertificate(): void
{
$client = static::createClient([], [
'HTTPS' => true,
]);
$client->request('GET', '/public');
self::assertResponseIsSuccessful();
}
public function testGetSecuredWithoutCertificate(): void
{
$client = static::createClient([], [
'HTTPS' => true,
]);
$client->request('GET', '/secured');
self::assertResponseStatusCodeSame(401);
}
}
Everything's fine!
The route /public is accessible and /secured is not anymore. Let's go deeper and check if we can have access with the right email.
We simulate the parameter SSL_CLIENT_S_DN
which is supposed to be given by Nginx.
public function testGetSecuredWithSSLClientRightEmailAddress(): void
{
$client = static::createClient([], [
'SSL_CLIENT_S_DN' => 'emailAddress=contact@pierrebelin.fr',
'SSL_CLIENT_VERIFY' => 'SUCCESS',
'HTTPS' => true,
]);
$client->request('GET', '/secured');
self::assertResponseIsSuccessful();
}
public function testGetSecuredWithSSLClientWrongEmailAddress(): void
{
$client = static::createClient([], [
'SSL_CLIENT_S_DN' => 'emailAddress=fake@pierrebelin.fr',
'SSL_CLIENT_VERIFY' => 'SUCCESS',
'HTTPS' => true,
]);
$client->request('GET', '/secured');
self::assertResponseStatusCodeSame(401);
}
The fake email address return 401, great!
We tested :
- /public is accessible
- /secured is only accessible with the correct email address of the certificate, otherwise, it returns a 401 error.
Improve safety 100% by adding Nginx checks
At this point, your Symfony API is not totally secure.
Why?
Let's check what we have :
- /public is accessible by everyone
- Nginx can transfer information from the client certificate to Symfony
- /secured is not accessible without a certificate with the email address "contact@pierrebelin.fr
Something is missing there.
You remember our 2 certificates client.crt
and notclient.crt
? They both working...
Nobody checks if the certificate has been signed by the authority. This is the most important step. Imagine that someone else generates a certificate with my email address. Here, he'll be able to access to /secured
.
The earlier the authenticator elements are validated, the easier it is to manage attacks on your service. By adding this security layer, you have minimized the impact of attacks on your API since Nginx will block it instead of sending the request to Symfony.
Directly in the Nginx configuration, we add a check on the client certificate.
# X509
ssl_client_certificate /PATH/TO/CERT/ca.crt;
ssl_verify_client optional_no_ca;
ssl_verify_depth 3;
Care, the option ssl_verify_client
is important to be optional_no_ca
or optional
but not on
or you will force a certificate on the entire API.
If you want to learn more about it, you can read the Nginx documentation.
Then, we force the validation on our route /secured
. It automatically checks the client certificate has been signed by the option ssl_client_certificate
.
#...
location ~ ^(\/secured)$ { # Don't forget to change here with your own regex
if ($ssl_client_verify != SUCCESS) {
return 403;
break;
}
try_files $uri /index.php$is_args$args;
}
#...
Important: Be careful on this step because it can create a lot of bugs. Do not forget to change the regex every time you create a new route that needs to be secured.
I recommend you to group all your critical routes inside a root route (ex: secured) to create one regex that accepts /secured
and also /secured/XXXX
. By doing this, you will be sure to forget any routes!
You can easily test this part with an API tool like Postman or Insomnia.
If you want to test further, I recommend you to use Guzzle. It implements an option to set a certificate as a parameter (exactly what we want to test).
Go further by checking other certificates fields
The next question is: Is it possible to use x509 authenticator with other fields than an email address?
For the moment, it is not possible. In fact, as you can see, it only filters the email address.
#vendor\symfony\security-http\Authenticator\X509Authenticator.php
protected function extractUsername(Request $request): string
{
$username = null;
if ($request->server->has($this->userKey)) {
$username = $request->server->get($this->userKey);
} elseif (
$request->server->has($this->credentialsKey)
&& preg_match('#emailAddress=([^,/@]++@[^,/]++)#', $request->server->get($this->credentialsKey), $matches)
) {
$username = $matches[1];
}
if (null === $username) {
throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey));
}
return $username;
}
But there are many other fields, especially the common name (CN) which is also used (it was for my client).
In this case, we'll have to override the default Symfony x509 authentication to change the regex inside to validate on the other fields of the certificate like the CN.
In the first step, we declare our x509 authenticator connected to our firewall secured
in the configuration.
#services.yaml
security.authenticator.x509.secured:
class: App\Security\X509Authenticator
arguments:
$userProvider: '@security.user.provider.concrete.client_certificate'
$firewallName: 'secured'
Then, we copy the whole class into the authenticator we created and change the regex to match the CN (or another field, whichever you want).
#src\EventListener\X509Authenticator.php
protected function extractUsername(Request $request): string
{
$username = null;
if ($request->server->has($this->userKey)) {
$username = $request->server->get($this->userKey);
} elseif (
$request->server->has($this->credentialsKey)
// We change the regex match to check on CN fields (no more on emailAddress)
&& preg_match('#CN=(.*?),.*#', $request->server->get($this->credentialsKey), $matches)
) {
$username = $matches[1];
}
if (null === $username) {
throw new BadCredentialsException(sprintf('SSL credentials not found: %s, %s', $this->userKey, $this->credentialsKey));
}
return $username;
}
Don't forget to change the user name in your Symfony configuration.
#security.yaml
providers:
client_certificate:
memory:
users:
MyCommonName: # Changed from contact@pierrebelin.fr
roles: ROLE_USER
It doesn't have to be an email. It can be your domain, your company name... Just remember that it must match the fields you filled when you generated your clients' certificates.
We simulate the parameter SSL_CLIENT_S_DN
with CN to test our override.
public function testGetSecuredWithSSLClientWithCN(): void
{
$client = static::createClient([], [
'SSL_CLIENT_S_DN' => 'CN=MyCommonName',
'SSL_CLIENT_VERIFY' => 'SUCCESS',
'HTTPS' => true,
]);
$client->request('GET', '/secured');
self::assertResponseStatusCodeSame(401);
}
It works! You can now authenticate on any field you want.
Do
You now know how to secure your Symfony API end-to-end.
Let me specify that it answers a particular request for safety. Do not implement it if you need to create an API to communicate with others if you don't need to communicate without a certificate.
The 2 parts are important to check if the security is activated:
- Symfony
security.yaml
to secure routes with x509 authenticator - Nginx
.conf
to handle client certificate by sending information from certificate to Symfony
Have fun!
Join the conversation.