Skip to content

Commit

Permalink
Init
Browse files Browse the repository at this point in the history
  • Loading branch information
Bartlomiej Chmura committed Sep 18, 2023
0 parents commit 89419a9
Show file tree
Hide file tree
Showing 19 changed files with 836 additions and 0 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2

- name: Install dependencies
run: composer install

- name: Run PHPUnit
run: vendor/bin/phpunit
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.idea/
/vendor/

/.phpunit.cache
/composer.lock
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Nuvola Cloudflare Turnstile Authenticator Bundle
[![.github/workflows/main.yaml](https://github.com/nuvolapl/cf-turnstile-authenticator-bundle/actions/workflows/main.yaml/badge.svg)](https://github.com/nuvolapl/cf-turnstile-authenticator-bundle/actions/workflows/main.yaml)

This bundle provides authentication based on the response from [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/).

## Configuration

### To install the bundle, follow these steps:

- The following parameters are required for bundle configuration in the `./config/packages/cf_turnstile_authenticator.yaml` file:

```yaml
cf_turnstile_authenticator:
secret_key: '%env(string:CF_TURNSTILE_AUTHENTICATOR_SECRET_KEY)%'
```
- add the `CF_TURNSTILE_AUTHENTICATOR_SECRET_KEY` environment variable to the `.env` file with a [dummy secret key](https://developers.cloudflare.com/turnstile/reference/testing/#dummy-sitekeys-and-secret-keys/)
- add the `CF_TURNSTILE_AUTHENTICATOR_SECRET_KEY` environment variable to the `.env.local` file with the secret key from [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/)

## Installation

### To install the bundle, follow these steps:

- Run the following command to install the bundle:

```shell
composer require nuvola/cloudflare-turnstile-authenticator-bundle
```
- add the bundle to the `./config/bundles.php` file:

```php
<?php
// ...
Nuvola\CloudflareTurnstileAuthenticatorBundle\CloudflareTurnstileAuthenticatorBundle::class => ['all' => true],
// ...
```

- to use the bundle, add the following code to the `./config/packages/security.yaml` file:

```yaml
security:
# ...
firewalls:
# ...
# adjust the name and pattern to your application!
public:
pattern: ^/api/public/
stateless: true
custom_authenticators:
- Nuvola\CloudflareTurnstileAuthenticatorBundle\Security\CloudflareTurnstileAuthenticator
# ...
access_control:
- { path: ^/api/public/, roles: IS_AUTHENTICATED_FULLY }
# ...
```

After adding this configuration, only authenticated by [response token](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) from the [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) will be passed.

## Usage
```shell
curl -H "x-cf-turnstile-response: $RESPONSE" https://api.nuvola.pl/api/public/users/7ff847d9-a2e0-4f93-9c00-b59ecd51a766
```
- $RESPONSE is a variable that stores [the token retrieved](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) in the web browser
40 changes: 40 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "nuvolapl/cf-turnstile-authenticator-bundle",
"type": "library",
"license": "proprietary",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": "^8.2",
"ext-json": "*",
"symfony/config": "^6.3",
"symfony/dependency-injection": "^6.3",
"symfony/http-client": "^6.3",
"symfony/http-foundation": "^6.3",
"symfony/http-kernel": "^6.3",
"symfony/security-core": "^6.3",
"symfony/security-http": "^6.3",
"symfony/uid": "^6.3",
"symfony/yaml": "^6.3"
},
"require-dev": {
"phpunit/phpunit": "^10.3"
},
"config": {
"optimize-autoloader": true,
"preferred-install": {
"*": "dist"
},
"sort-packages": true
},
"autoload": {
"psr-4": {
"Nuvola\\CloudflareTurnstileAuthenticatorBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Nuvola\\CloudflareTurnstileAuthenticatorBundle\\Tests\\": "tests/"
}
}
}
23 changes: 23 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>

<source restrictDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
17 changes: 17 additions & 0 deletions src/CloudflareTurnstileAuthenticatorBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle;

use Nuvola\CloudflareTurnstileAuthenticatorBundle\DependencyInjection\CloudflareTurnstileAuthenticatorExtension;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;

final class CloudflareTurnstileAuthenticatorBundle extends Bundle
{
public function getContainerExtension(): ?ExtensionInterface
{
return new CloudflareTurnstileAuthenticatorExtension();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle\DependencyInjection;

use Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteService;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;

final class CloudflareTurnstileAuthenticatorExtension extends ConfigurableExtension
{
public function loadInternal(array $mergedConfig, ContainerBuilder $container): void
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);

$loader->load('services.yaml');

$definition = $container->getDefinition(SiteService::class);
$definition->replaceArgument(1, $mergedConfig['endpoint']);
$definition->replaceArgument(2, $mergedConfig['secret_key']);
}

public function getAlias(): string
{
return 'cf_turnstile_authenticator';
}
}
27 changes: 27 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

final class Configuration implements ConfigurationInterface
{
public const DEFAULT_CF_TRUNSTILE_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('cf_turnstile_authenticator');
$treeBuilder->getRootNode()
->children()
->scalarNode('endpoint')->cannotBeEmpty()->defaultValue(self::DEFAULT_CF_TRUNSTILE_ENDPOINT)->end()
->scalarNode('secret_key')->isRequired()->cannotBeEmpty()->end()
->end()
->end()
;

return $treeBuilder;
}
}
27 changes: 27 additions & 0 deletions src/EventDispatcher/Event/ResponseVerifiedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle\EventDispatcher\Event;

use Symfony\Component\Security\Core\User\UserInterface;

final class ResponseVerifiedEvent
{
private ?UserInterface $user = null;

public function getUser(): ?UserInterface
{
return $this->user;
}

public function isUserSet(): bool
{
return $this->user instanceof UserInterface;
}

public function setUser(UserInterface $user): void
{
$this->user = $user;
}
}
19 changes: 19 additions & 0 deletions src/Resources/config/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
services:
_defaults:
autowire: false
autoconfigure: false

nuvola.http_client:
class: Symfony\Contracts\HttpClient\HttpClientInterface
factory: [ 'Symfony\Component\HttpClient\HttpClient', 'create' ]

Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteService:
- '@nuvola.http_client'
- ~ # compiled
- ~ # compiled

Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteServiceInterface: '@Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteService'

Nuvola\CloudflareTurnstileAuthenticatorBundle\Security\CloudflareTurnstileAuthenticator:
- '@Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteServiceInterface'
- '@Symfony\Contracts\EventDispatcher\EventDispatcherInterface'
83 changes: 83 additions & 0 deletions src/Security/CloudflareTurnstileAuthenticator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle\Security;

use Nuvola\CloudflareTurnstileAuthenticatorBundle\EventDispatcher\Event\ResponseVerifiedEvent;
use Nuvola\CloudflareTurnstileAuthenticatorBundle\Security\User\NullUser;
use Nuvola\CloudflareTurnstileAuthenticatorBundle\Service\SiteServiceInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Uid\Uuid;

final class CloudflareTurnstileAuthenticator extends AbstractAuthenticator
{
private const HEADER_NAME = 'x-cf-turnstile-response';

public function __construct(
private readonly SiteServiceInterface $siteService,
private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {}

public function supports(Request $request): ?bool
{
return $request->headers->has(self::HEADER_NAME);
}

public function authenticate(Request $request): Passport
{
$response = $request->headers->get(self::HEADER_NAME);

if (null === $response) {
throw new CustomUserMessageAuthenticationException(
sprintf('Header "%s" cannot be empty.', self::HEADER_NAME)
);
}

$idempotencyKey = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_DNS), hash('ripemd160', $response))->toRfc4122();

try {
$this->siteService->verify($response, $idempotencyKey, $request->getClientIp());
} catch (\RuntimeException $e) {
throw new TokenNotFoundException('Invalid token.', 0, $e);
}

$event = $this->eventDispatcher?->dispatch(new ResponseVerifiedEvent());

return new SelfValidatingPassport(
new UserBadge(
$idempotencyKey,
function (string $identifier) use ($event) {
if ($event && $event->isUserSet()) {
return $event->getUser();
}

return new NullUser();
}
),
);
}

// f57edf2a-9f12-52ec-8b95-e9b8dac04ac1
// f57edf2a-9f12-52ec-8b95-e9b8dac04ac1
// f57edf2a-9f12-52ec-8b95-e9b8dac04ac1
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return null;
}
}
22 changes: 22 additions & 0 deletions src/Security/User/NullUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Nuvola\CloudflareTurnstileAuthenticatorBundle\Security\User;

use Symfony\Component\Security\Core\User\UserInterface;

final class NullUser implements UserInterface
{
public function getRoles(): array
{
return [];
}

public function eraseCredentials(): void {}

public function getUserIdentifier(): string
{
return '';
}
}
Loading

0 comments on commit 89419a9

Please sign in to comment.