Imagine you have the following scenario:
- There are 2 Symfony apps deployed in different domains or subdomains (e.g.
users.example.com
&website.example.com
). - You have to log in using the first app but there are not persisted users in this one. The users, required for the authentication process, are located in the second one.
- Once you logged in the first app, when required (by Symfony), it needs to refresh the session user (e.g. every time a protected page is loaded) from the remote source.
How do you achieve that the first app communicates with the second one to handle the authentication/user refresher tasks in a fluid way?
Well, this is where writing a custom authenticator and a custom user provider come handy.
This is my proposal about how to handle this scenario. Possibly, there are other ways (maybe easier), but this is the mine one.
The stack used for this implementation is: Symfony 7.0.5 + PHP 8.2 + Sqlite 3.43.
You have to run the following commands for both apps:
- composer install
- symfony console doctrine:database:create
- symfony console doctrine:migrations:migrate
- symfony console doctrine:fixtures:load
Then, you have to run a web server. Here, I used the Symfony's server:
symfony server:start --no-tls --port=8000 -d
symfony server:start --no-tls --port=8001 -d
If you want to use your own webserver remember, set different domains/subdomains for each app, or set different ports for them,
you have to customize the env var REMOTE_AUTH_HOST
, located in the app #1 to set the domain of the app that contains
the users:
REMOTE_AUTH_HOST=http://localhost:8001
Set the var according your specific scenario.
This App exposes 2 endpoints:
- Authenticate user:
POST http://localhost:8001/login
In the payload you pass the credentials:
{
"email": "[email protected]",
"password": "123456"
}
The following HTTP codes are returned:
- If authentication fails:
HTTP 401
- If authentication is ok:
HTTP 200
This request will be invoked when the Login process is performed by the App #1
- Refresh user:
GET http://localhost:8001/users/show?email=<the-user-email-requested>
This request will be invoked every time Symfony requires to refresh the session user.
The following HTTP codes are returned:
- If authentication fails:
HTTP 404
- If authentication is ok:
HTTP 200
Please, review the code under users.example.com
folder, in order to have a full understanding
about how these endpoints have been implemented.
Here, you need to write a custom authenticator in order to tell Symfony where to look the users to perform the authentication process:
<?php
//...
class RemoteUserAuthenticator extends AbstractLoginFormAuthenticator
{
final public const LOGIN_ROUTE_NAME = 'app_login';
final public const HOME_ROUTE_NAME = 'app_home_index';
public function __construct(
private readonly RemoteAuthenticatorService $remoteAuthenticatorService,
private readonly UrlGeneratorInterface $urlGenerator
) {
}
public function supports(Request $request): bool
{
$route = $request->attributes->get('_route');
return (self::LOGIN_ROUTE_NAME === $route) && $request->isMethod('POST');
}
public function authenticate(Request $request): SelfValidatingPassport
{
$userIdentifier = $request->get('_username');
$password = $request->get('_password');
$csrfToken = $request->get('_csrf_token');
$userBadge = new UserBadge($userIdentifier, function (string $email) use ($password) {
$user = $this->remoteAuthenticatorService->execute($email, $password);
if (!$user) {
throw new UserNotFoundException();
}
return $user;
});
return new SelfValidatingPassport($userBadge, [
new CsrfTokenBadge('authenticate', $csrfToken)
]);
}
// ...
}
As you can see, the authenticator gets the user's credentials and pass them to the remoteAuthenticatorService
where
the authentication request is performed.
About the provider needed to refresh the session user, you have to implement a custom UserProvider
:
readonly class RemoteUserProvider implements UserProviderInterface
{
public function __construct(private RemoteUserFetcherService $remoteUserFetcherService)
{
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
}
return $this->remoteUserFetcherService->execute($user->getUserIdentifier());
}
public function supportsClass(string $class): bool
{
return User::class === $class || is_subclass_of($class, User::class);
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
return $this->remoteUserFetcherService->execute($identifier);
}
}
As you can see, the provider invokes the request POST /login
every time Symfony requires to refresh the session user.
Finally, you have to set up a couple of things in the security.yaml
in order to tell Symfony to use the custom authenticator and
the custom user provider:
- Set the custom provider:
security:
#...
providers:
app_user_provider:
id: App\Security\RemoteUserProvider
#...
- Set the custom authenticator:
security:
#...
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
target: app_login
custom_authenticators:
- App\Security\RemoteUserAuthenticator
entry_point: App\Security\RemoteUserAuthenticator
#...
And that's it!
When the users perform a login in the App #1, you can see how Symfony uses the authenticator defined to perform the remote auth:
- Failed authentication
- Authentication succeeded:
- Every time that Symfony needs to refresh the session user, it uses the custom service provider to accomplish it. The refreshed user is returned from the remote app:
And that's it!
Please, review the code in the folder website.example.com
in order to have a full understanding about
the implementation here described!
I hope you like what I've done. If so, please leave a comment. If you think I could do the things in a different (and easier) way, please leave a comment. I would appreciate it.
Thanks so much!,
Cobis