Symfony 6 ApiKeyAuthenticator with SelfValidatingPassport replaces guard? - php

I migrating a symfony 5.1 api to symfony 6 with api-platform.
My app has it's own user and password logic different to common user database so I had to create my UserRepository and UserProvider.
I have created a controller with Login functionality that checks credentials and returns a token.
On symfony 5, I had implemented an AbstractGuardAuthenticator to verify the token and load the user.
On symfony 6 I use the new system implementing an AbstractAuthenticator (Personal opinion: less clear than guard).
security:
enable_authenticator_manager: true
# [...]
providers:
# used to reload user from session & other features (e.g. switch_user)
api_user_provider:
id: App\Security\UserProvider
firewalls:
# [...]
api:
pattern: ^/api/
stateless: true
provider: api_user_provider
custom_authenticators:
- App\Security\TokenAuthenticator
<?php
namespace App\Security;
// usings
class TokenAuthenticator extends AbstractAuthenticator
{
// [...]
public function supports(Request $request): ?bool
{
if ( $request->headers->has('Authorization') ) {
return true;
} else {
throw new AuthenticationException('Authorization header is missing');
}
}
public function authenticate(Request $request): Passport
{
$token = $this->getToken($request);
return new SelfValidatingPassport(
new UserBadge($token, function ($token): UserInterface {
return $this->getUserFromToken($token);
}), []
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return New JsonResponse(["result"=> "ok"]);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(["result" => "error"], Response::HTTP_UNAUTHORIZED);
}
}
When I make a simple call to an Endpoint that requires the user to be logged, e.g:
GET http://localhost:8000/api/categories
Accept: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQi[...]
I expect a list of categories, but I receive a json from onAuthenticationSuccess:
{"result":"ok"}
So I think I'm missunderstanding the security system. Please help me.
What I'm doing wrong?

It's simple, you almost had it.
onAuthenticationSuccess must return null to let your request continue.
Your are interrupting your original request when returning a json: {"result": "ok"}.
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}

Related

Symfony 5 ApiKeyAuthenticator with SelfValidatingPassport

I am working on a new Symfony 5.3.6 project and want to implement authentication, based on the new system as stated in:
https://symfony.com/doc/current/security/authenticator_manager.html#creating-a-custom-authenticator
I do not have any users and just want to check if the sent api token is correct, so when implementing this method:
public function authenticate(Request $request): PassportInterface
{
$apiToken = $request->headers->get('X-AUTH-TOKEN');
if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status Code 401 "Unauthorized"
throw new CustomUserMessageAuthenticationException('No API token provided');
}
return new SelfValidatingPassport(new UserBadge($apiToken));
}
where exactly is the checking done? Have i forgotten to implement another Class somewhere?
If I leave the code as is it lands directly in onAuthenticationFailure.
I understand, that I could implement Users/UserProvider with an attribute $apiToken and then the system would check if the database entry corresponds with the token in the request. But i do not have users.
It should be possible without having users, because on the above URL, it says:
Self Validating Passport
If you don’t need any credentials to be checked (e.g. when using API
tokens), you can use the
Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport.
This class only requires a UserBadge object and optionally Passport
Badges.
But that is a little thin. How do I "use" it?
Ok, I think I got the point, in any case, you need to handle some User & then you need to create a customer Userprovider.
Here my logic:
App\Security\UserProvider:
class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
public function loadUserByIdentifier($identifier): UserInterface
{
if ($identifier === 'YOUR_API_KEY') {
return new User();
}
throw new UserNotFoundException('API Key is not correct');
}
...
App\Security\ApiKeyAuthenticator:
class ApiKeyAuthenticator extends AbstractAuthenticator
{
private UserProvider $userProvider;
public function __construct(UserProvider $userProvider)
{
$this->userProvider = $userProvider;
}
public function supports(Request $request): ?bool
{
// allow api docs page
return trim($request->getPathInfo(), '/') !== 'docs';
}
public function authenticate(Request $request): Passport
{
$apiToken = $request->headers->get('X-API-KEY');
if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
throw new CustomUserMessageAuthenticationException('No API token provided');
}
return new SelfValidatingPassport(
new UserBadge($apiToken, function () use ($apiToken) {
return $this->userProvider->loadUserByIdentifier($apiToken);
})
);
}
It works for me, my API is protected by a basic API Key in the header. I don't know if it's the best way, but seems ok.
And define in your security.yaml:
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
id: App\Security\UserProvider
You can use next validation
return new SelfValidatingPassport(
new UserBadge($apiToken, function() use ($apiToken) {
// TODO: here you can implement any check
})
);

Symfony 4.4: How to create Keycloak authentication in functionnal tests?

I'm trying to upgrade a Symfony 4.3.6 application to v4.4.
The app uses oauth2-client-bundle to authenticate users with Keycloak. Consequently, users are never persisted in database.
Here is the security config:
security:
providers:
oauth:
id: knpu.oauth2.user_provider
firewalls:
main:
logout:
path: app.logout
anonymous: true
guard:
authenticators:
- App\Security\KeycloakAuthenticator
access_control:
- { path: ^/connect, roles: IS_AUTHENTICATED_ANONYMOUSLY }
-
path: ^/
allow_if: "'127.0.0.1' == request.getClientIp() or is_granted('IS_AUTHENTICATED_FULLY')"
And here is the KeycloakAuthenticator service:
<?php
namespace App\Security;
use App\Entity\User; // extends KnpU\OAuth2ClientBundle\Security\User\OAuthUser
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\Provider\KeycloakClient;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class KeycloakAuthenticator extends SocialAuthenticator
{
private $clientRegistry;
private $router;
public function __construct(ClientRegistry $clientRegistry, RouterInterface $router)
{
$this->clientRegistry = $clientRegistry;
$this->router = $router;
}
public function supports(Request $request)
{
return 'connect_keycloak_check' === $request->attributes->get('_route');
}
public function getCredentials(Request $request)
{
return $this->fetchAccessToken($this->getKeycloakClient());
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
$keycloakUser = $this->getKeycloakClient()->fetchUserFromToken($credentials);
$user = new User($keycloakUser->getName(), ['IS_AUTHENTICATED_FULLY', 'ROLE_USER']);
$user->setEmail($keycloakUser->getEmail())
->setName($keycloakUser->getName())
->setLocale($keycloakUser->toArray()['locale'] ?? 'fr')
;
return $user;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
// On success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
public function start(Request $request, AuthenticationException $authException = null)
{
return new RedirectResponse($this->router->generate('app.keycloak_start'), Response::HTTP_TEMPORARY_REDIRECT);
}
private function getKeycloakClient(): KeycloakClient
{
return $this->clientRegistry->getClient('keycloak');
}
}
Functional tests are developped according to the official documentation:
public function testHomePage()
{
$client = static::createClient();
$session = $client->getContainer()->get('session');
$firewallName = 'main';
$token = new PostAuthenticationGuardToken(new User('foo', ['ROLE_USER']), $firewallName, $roles);
$session->set('_security_'.$firewallName, serialize($token));
$session->save();
$cookie = new Cookie($session->getName(), $session->getId());
$client->getCookieJar()->set($cookie);
$client->request('GET', '/');
$this->assertTrue($client->getResponse()->isSuccessful());
}
Until now, tests pass, everything is fine.
But since I upgraded Symfony to 4.4, the method ControllerTrait::getUser() returns null and I'm facing the following error when running functional tests:
Error : Call to a member function getUsername() on null
I except to get the current user as usual when calling $this->getUser().
I tried to manually set the token in the security.token_storage but the error still persists.
I also tried to force authentication by removing the part "'127.0.0.1' == request.getClientIp() in the allow_if section of "security.yaml", and the response is now a 307 Temporary Redirect to the Keycloak service.
Did the behavior of $this->getUser() change between this 2 versions ?
Thank you for your help

Symfony - Authentication with an API Token - Request token user is null

For the record, I'm using PHP 7.0.0, in a Vagrant Box, with PHPStorm. Oh, and Symfony 3.
I'm following the API Key Authentication documentation. My goal is:
To allow the user to provide a key as a GET apiKey parameter to authenticate for any route, except the developer profiler etc obviously
To allow the developer to write $request->getUser() in a controller to get the currently logged in user
My problem is that, although I believe I've followed the documentation to the letter, I'm still getting a null for $request->getUser() in the controller.
Note: I've removed error checking to keep the code short
ApiKeyAuthenticator.php
The thing that processes the part of the request to grab the API key from it. It can be a header or anything, but I'm sticking with apiKey from GET.
Differences from documentation, pretty much 0 apart from that I'm trying to keep the user authenticated in the session following this part of the docs.
class ApiKeyAuthenticator implements SimplePreAuthenticatorInterface
{
public function createToken(Request $request, $providerKey)
{
$apiKey = $request->query->get('apiKey');
return new PreAuthenticatedToken(
'anon.',
$apiKey,
$providerKey
);
}
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
{
$apiKey = $token->getCredentials();
$username = $userProvider->getUsernameForApiKey($apiKey);
// The part where we try and keep the user in the session!
$user = $token->getUser();
if ($user instanceof ApiKeyUser) {
return new PreAuthenticatedToken(
$user,
$apiKey,
$providerKey,
$user->getRoles()
);
}
$user = $userProvider->loadUserByUsername($username);
return new PreAuthenticatedToken(
$user,
$apiKey,
$providerKey,
$user->getRoles()
);
}
public function supportsToken(TokenInterface $token, $providerKey)
{
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
}
}
ApiKeyUserProvider.php
The custom user provider to load a user object from wherever it can be loaded from - I'm sticking with the default DB implementation.
Differences: only the fact that I have to inject the repository into the constructor to make calls to the DB, as the docs allude to but don't show, and also returning $user in refreshUser().
class ApiKeyUserProvider implements UserProviderInterface
{
protected $repo;
// I'm injecting the Repo here (docs don't help with this)
public function __construct(UserRepository $repo)
{
$this->repo = $repo;
}
public function getUsernameForApiKey($apiKey)
{
$data = $this->repo->findUsernameByApiKey($apiKey);
$username = (!is_null($data)) ? $data->getUsername() : null;
return $username;
}
public function loadUserByUsername($username)
{
return $this->repo->findOneBy(['username' => $username]);
}
public function refreshUser(UserInterface $user)
{
// docs state to return here if we don't want stateless
return $user;
}
public function supportsClass($class)
{
return 'Symfony\Component\Security\Core\User\User' === $class;
}
}
ApiKeyUser.php
This is my custom user object.
The only difference I have here is that it contains doctrine annotations (removed for your sanity) and a custom field for the token. Also, I removed \Serializable as it didn't seem to be doing anything and apparently Symfony only needs the $id value to recreate the user which it can do itself.
class ApiKeyUser implements UserInterface
{
private $id;
private $username;
private $password;
private $email;
private $salt;
private $apiKey;
private $isActive;
public function __construct($username, $password, $salt, $apiKey, $isActive = true)
{
$this->username = $username;
$this->password = $password;
$this->salt = $salt;
$this->apiKey = $apiKey;
$this->isActive = $isActive;
}
//-- SNIP getters --//
}
security.yml
# Here is my custom user provider class from above
providers:
api_key_user_provider:
id: api_key_user_provider
firewalls:
# Authentication disabled for dev (default settings)
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# My new settings, with stateless set to false
secured_area:
pattern: ^/
stateless: false
simple_preauth:
authenticator: apikey_authenticator
provider:
api_key_user_provider
services.yml
Obviously I need to be able to inject the repository into the provider.
api_key_user_repository:
class: Doctrine\ORM\EntityRepository
factory: ["#doctrine.orm.entity_manager", getRepository]
arguments: [AppBundle\Security\ApiKeyUser]
api_key_user_provider:
class: AppBundle\Security\ApiKeyUserProvider
factory_service: doctrine.orm.default_entity_manager
factory_method: getRepository
arguments: ["#api_key_user_repository"]
apikey_authenticator:
class: AppBundle\Security\ApiKeyAuthenticator
public: false
Debugging. It's interesting to note that, in ApiKeyAuthenticator.php, the call to $user = $token->getUser(); in authenticateToken() always shows an anon. user, so it's clearly not being stored in the session.
Also note how at the bottom of the authenticator we do actually return a new PreAuthenticatedToken with a user found from the database:
So it's clearly found me and is returning what it's supposed to here, but the user call in the controller returns null. What am I doing wrong? Is it a failure to serialise into the session because of my custom user or something? I tried setting all the user properties to public as somewhere in the documentation suggested but that made no difference.
So it turns out that calling $request->getUser() in the controller doesn't actually return the currently authenticated user as I would have expected it to. This would make the most sense for this object API imho.
If you actually look at the code for Request::getUser(), it looks like this:
/**
* Returns the user.
*
* #return string|null
*/
public function getUser()
{
return $this->headers->get('PHP_AUTH_USER');
}
That's for HTTP Basic Auth! In order to get the currently logged in user, you need to do this every single time:
$this->get('security.token_storage')->getToken()->getUser();
This does, indeed, give me the currently logged in user. Hopefully the question above shows how to authenticate successfully by API token anyway.
Alternatively, don't call $this->get() as it's a service locator. Decouple yourself from the controller and inject the token service instead to get the token and user from it.
To get the currently logged in User inside your Controller simply call:
$this->getUser();
This will refer to a method in Symfony's ControllerTrait, which basically wraps the code provided in Jimbo's answer.
protected function getUser()
{
if (!$this->container->has('security.token_storage')) {
throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".');
}
if (null === $token = $this->container->get('security.token_storage')->getToken()) {
return;
}
if (!is_object($user = $token->getUser())) {
// e.g. anonymous authentication
return;
}
return $user;
}

Security throwns 500 exception with Token was not found in TokenStorage instead of 403 or 401

I make authorization by ApiKey and I want to get 401 Unauthorized if no authorization data presented, and 403 Forbidden if authorization data is invalid. But I got 500 Internal Server Error in both situations.
security.yml:
security:
providers:
api_key_user_provider:
entity:
class: RestBundle:RestUser
property: apikey
firewalls:
rest_api_area:
pattern: ^/api
stateless: true
rest_auth:
header: x-apikey
provider: api_key_user_provider
access_control:
- { path: ^/api, roles: ROLE_REST_USER }
RestUserListener.php:
class RestUserListener implements ListenerInterface
{
protected $tokenStorage;
protected $authenticationManager;
private $header;
function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager, $header)
{
$this->tokenStorage = $tokenStorage;
$this->authenticationManager = $authenticationManager;
$this->header = $header;
}
public function handle(GetResponseEvent $event)
{
$request = $event->getRequest();
$apikey = $request->headers->get($this->header);
if (!$apikey) return;
$token = new RestUserToken();
$token->setUser($apikey);
$authToken = $this->authenticationManager->authenticate($token);
$this->tokenStorage->setToken($authToken);
return;
}
}
RestUserAuthenticationProvider.php:
class RestUserAuthenticationProvider implements AuthenticationProviderInterface
{
private $userProvider;
public function __construct(UserProviderInterface $userProvider)
{
$this->userProvider = $userProvider;
}
public function authenticate(TokenInterface $token)
{
$user = $this->userProvider->loadUserByUsername($token->getUsername());
if ($user)
{
$authenticatedToken = new RestUserToken($user->getRoles());
$authenticatedToken->setUser($user);
return $authenticatedToken;
}
throw new AuthenticationException("Apikey not found.");
}
public function supports(TokenInterface $token)
{
return $token instanceof RestUserToken;
}
}
RestUserToken as simple as AbstractToken and has no additional logic.
api_key_user_provider is the standard entity provider identified by apikey property of RestUser
RestUserFactory also has no additional magic inside, just like in the official documentation
The RestUserListener::handle() method should handle the case of returning HTTP 401 or HTTP 403.
Simply having return; is not going to make this happen.
In a similar application I wrote, I did this:
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
//
...
//
public function handle(GetResponseEvent $event)
{
$request = $event->getRequest();
if ( /* something invalid here, so error */ ) {
$context = $request->getHost();
throw new UnauthorizedHttpException(
"Basic realm=\"$context\"",
'Please authenticate correctly or any other message here'
);
}
}
Throwing UnauthorizedHttpException will result in a HTTP 401 (you'll understand if you'll look at the source code of the exception).
For HTTP 403, you can use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException instead.

symfony2 logout

My problem is capture user logout. the code what i have is:
public function onAuthenticationFailure(Request $request, AuthenticationException $exception){
return new Response($this->translator->trans($exception->getMessage()));
}
public function logout(Request $request, Response $response, TokenInterface $token)
{
$empleado = $token->getUser();
$log = new Log();
$log->setFechalog(new \DateTime('now'));
$log->setTipo("Out");
$log->setEntidad("");
$log->setEmpleado($empleado);
$this->em->persist($log);
$this->em->flush();
}
public function onLogoutSuccess(Request $request) {
return new RedirectResponse($this->router->generate('login'));
}
The problem is I can not access the user token TokenInterface when you are running the logout function?
To get token, you must inject with security context.
1. Create class Logout listener, something like this:
namespace Yourproject\Yourbundle\Services;
...
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
use Symfony\Component\Security\Core\SecurityContext;
class LogoutListener implements LogoutSuccessHandlerInterface {
private $security;
public function __construct(SecurityContext $security) {
$this->security = $security;
}
public function onLogoutSuccess(Request $request) {
$user = $this->security->getToken()->getUser();
//add code to handle $user here
//...
$response = RedirectResponse($this->router->generate('login'));
return $response;
}
}
2. And then in service.yml, add this line:
....
logout_listener:
class: Yourproject\Yourbundle\Services\LogoutListener
arguments: [#security.context]
That's it, may it helps.
See http://symfony.com/doc/current/reference/configuration/security.html
security.yml
secured_area:
logout:
path: /logout
**success_handler: logout_listener**
Take a look here were you can overwrite any controller of the bundle:
http://symfony.com/doc/current/cookbook/bundles/inheritance.html

Categories