├── .gitignore ├── Exception ├── SteamAuthenticationException.php └── InvalidCallbackPayloadException.php ├── Event ├── FirstLoginEvent.php ├── PayloadValidEvent.php ├── CommunityIdAwareEvent.php ├── AuthenticateUserEvent.php └── CallbackReceivedEvent.php ├── Validator ├── MatchesLoginCallbackRoute.php └── MatchesLoginCallbackRouteValidator.php ├── KnojectorSteamAuthenticationBundle.php ├── Resources ├── views │ └── button.html.twig └── config │ └── services.xml ├── DependencyInjection ├── Configuration.php └── KnojectorSteamAuthenticationExtension.php ├── composer.json ├── LICENSE ├── ArgumentResolver └── SteamCallbackResolver.php ├── Controller └── SteamController.php ├── Subscriber ├── LoadUserSubscriber.php ├── AuthenticateUserSubscriber.php └── ValidateCallbackReceivedSubscriber.php ├── DTO └── SteamCallback.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | -------------------------------------------------------------------------------- /Exception/SteamAuthenticationException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | abstract class SteamAuthenticationException extends \Exception 9 | { 10 | } -------------------------------------------------------------------------------- /Exception/InvalidCallbackPayloadException.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class InvalidCallbackPayloadException extends SteamAuthenticationException 9 | { 10 | } -------------------------------------------------------------------------------- /Event/FirstLoginEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class FirstLoginEvent extends CommunityIdAwareEvent 9 | { 10 | CONST NAME = 'knojector.steam_authentication_bundle.first_login'; 11 | } -------------------------------------------------------------------------------- /Event/PayloadValidEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class PayloadValidEvent extends CommunityIdAwareEvent 9 | { 10 | CONST NAME = 'knojector.steam_authentication_bundle.payload_valid'; 11 | } -------------------------------------------------------------------------------- /Validator/MatchesLoginCallbackRoute.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | #[\Attribute] 11 | class MatchesLoginCallbackRoute extends Constraint 12 | { 13 | public string $message = 'The parameter "openid_return_to" with value "{{ url }}" does not match original callback url "{{ expected }}".'; 14 | } -------------------------------------------------------------------------------- /Event/CommunityIdAwareEvent.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | abstract class CommunityIdAwareEvent extends Event 11 | { 12 | public function __construct(private string $communityId) 13 | {} 14 | 15 | public function getCommunityId(): string 16 | { 17 | return $this->communityId; 18 | } 19 | } -------------------------------------------------------------------------------- /KnojectorSteamAuthenticationBundle.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class KnojectorSteamAuthenticationBundle extends Bundle 12 | { 13 | /** 14 | * @inheritDoc 15 | */ 16 | public function build(ContainerBuilder $container) 17 | { 18 | parent::build($container); 19 | } 20 | } -------------------------------------------------------------------------------- /Event/AuthenticateUserEvent.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class AuthenticateUserEvent extends Event 12 | { 13 | CONST NAME = 'knojector.steam_authentication_bundle.authenticate_user'; 14 | 15 | public function __construct(protected UserInterface $user) 16 | {} 17 | 18 | public function getUser(): UserInterface 19 | { 20 | return $this->user; 21 | } 22 | } -------------------------------------------------------------------------------- /Event/CallbackReceivedEvent.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class CallbackReceivedEvent extends Event 12 | { 13 | CONST NAME = 'knojector.steam_authentication_bundle.callback_received'; 14 | 15 | public function __construct(protected SteamCallback $steamCallback) 16 | {} 17 | 18 | public function getSteamCallback(): SteamCallback 19 | { 20 | return $this->steamCallback; 21 | } 22 | } -------------------------------------------------------------------------------- /Resources/views/button.html.twig: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
-------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class Configuration implements ConfigurationInterface 12 | { 13 | /** 14 | * @inheritDoc 15 | */ 16 | public function getConfigTreeBuilder(): TreeBuilder 17 | { 18 | $treeBuilder = new TreeBuilder('knojector_steam_authentication'); 19 | 20 | $treeBuilder->getRootNode() 21 | ->children() 22 | ->scalarNode('login_failure_redirect')->isRequired()->end() 23 | ->scalarNode('login_success_redirect')->isRequired()->end() 24 | ->end() 25 | ; 26 | 27 | return $treeBuilder; 28 | } 29 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "knojector/steam-authentication-bundle", 3 | "license": "MIT", 4 | "type": "symfony-bundle", 5 | "description": "Symfony Bundle to integrate Steam authentication", 6 | "authors": [ 7 | { 8 | "name": "knojector", 9 | "email": "dev@knojector.xyz" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { "Knojector\\SteamAuthenticationBundle\\": "" } 14 | }, 15 | "require": { 16 | "php": ">=8.2", 17 | "symfony/config": "^5.3|^6.0|^7.0", 18 | "symfony/dependency-injection": "^5.3|^6.0|^7.0", 19 | "symfony/event-dispatcher": "^5.3|^6.0|^7.0", 20 | "symfony/expression-language": "^5.3|^6.0|^7.0", 21 | "symfony/framework-bundle": "^5.3|^6.0|^7.0", 22 | "symfony/http-client": "^5.3|^6.0|^7.0", 23 | "symfony/security-bundle": "^5.3|^6.0|^7.0", 24 | "symfony/routing": "^5.3|^6.0|^7.0", 25 | "symfony/validator": "^5.3|^6.0|^7.0" 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /DependencyInjection/KnojectorSteamAuthenticationExtension.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class KnojectorSteamAuthenticationExtension extends Extension 14 | { 15 | /** 16 | * @inheritDoc 17 | */ 18 | public function load(array $configs, ContainerBuilder $container) 19 | { 20 | $loader = new XmlFileLoader($container, new FileLocator(dirname(__DIR__).'/Resources/config')); 21 | $loader->load('services.xml'); 22 | 23 | $configuration = new Configuration(); 24 | $config = $this->processConfiguration($configuration, $configs); 25 | 26 | $container->setParameter('knojector.steam_authentication.login_failure_redirect', $config['login_failure_redirect']); 27 | $container->setParameter('knojector.steam_authentication.login_success_redirect', $config['login_success_redirect']); 28 | } 29 | } -------------------------------------------------------------------------------- /ArgumentResolver/SteamCallbackResolver.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class SteamCallbackResolver implements ArgumentValueResolverInterface 16 | { 17 | public function __construct(private ValidatorInterface $validator) 18 | {} 19 | 20 | /** 21 | * @inheritDoc 22 | */ 23 | public function supports(Request $request, ArgumentMetadata $argument): bool 24 | { 25 | return $argument->getType() === SteamCallback::class; 26 | } 27 | 28 | /** 29 | * @inheritDoc 30 | */ 31 | public function resolve(Request $request, ArgumentMetadata $argument): iterable 32 | { 33 | $steamCallback = SteamCallback::fromRequest($request); 34 | 35 | $errors = $this->validator->validate($steamCallback); 36 | if (count($errors) > 0) { 37 | throw new BadRequestHttpException((string) $errors); 38 | } 39 | 40 | yield $steamCallback; 41 | } 42 | } -------------------------------------------------------------------------------- /Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Validator/MatchesLoginCallbackRouteValidator.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class MatchesLoginCallbackRouteValidator extends ConstraintValidator 15 | { 16 | public function __construct(private UrlGeneratorInterface $urlGenerator) 17 | {} 18 | 19 | /** 20 | * @inheritDoc 21 | */ 22 | public function validate($value, Constraint $constraint): void 23 | { 24 | if (!$constraint instanceof MatchesLoginCallbackRoute) { 25 | throw new UnexpectedTypeException($constraint, MatchesLoginCallbackRoute::class); 26 | } 27 | 28 | if (null === $value || '' === $value) { 29 | return; 30 | } 31 | 32 | if (!is_string($value)) { 33 | throw new UnexpectedValueException($value, 'string'); 34 | } 35 | 36 | $expected = $this->urlGenerator->generate('steam_authentication_callback', [], UrlGeneratorInterface::ABSOLUTE_URL); 37 | if ($expected !== $value) { 38 | $this->context->buildViolation($constraint->message) 39 | ->setParameter('{{ url }}', $value) 40 | ->setParameter('{{ expected }}', $expected) 41 | ->addViolation(); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Controller/SteamController.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | */ 19 | #[Route(path: '/steam')] 20 | class SteamController extends AbstractController 21 | { 22 | public function __construct( 23 | private EventDispatcherInterface $eventDispatcher, 24 | private UrlGeneratorInterface $urlGenerator 25 | ) {} 26 | 27 | #[Route(path: '/callback')] 28 | public function callback(SteamCallback $callback): Response 29 | { 30 | try { 31 | $this->eventDispatcher->dispatch(new CallbackReceivedEvent($callback), CallbackReceivedEvent::NAME); 32 | } catch (SteamAuthenticationException $e) { 33 | return new RedirectResponse( 34 | $this->urlGenerator->generate($this->getParameter('knojector.steam_authentication.login_failure_redirect')) 35 | ); 36 | } 37 | 38 | return new RedirectResponse( 39 | $this->urlGenerator->generate($this->getParameter('knojector.steam_authentication.login_success_redirect')) 40 | ); 41 | } 42 | } -------------------------------------------------------------------------------- /Subscriber/LoadUserSubscriber.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class LoadUserSubscriber implements EventSubscriberInterface 17 | { 18 | public function __construct( 19 | private EventDispatcherInterface $eventDispatcher, 20 | private UserProviderInterface $userProvider 21 | ) {} 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | public static function getSubscribedEvents(): array 27 | { 28 | return [ 29 | PayloadValidEvent::NAME => [ 30 | ['onPayloadValid', 10] 31 | ] 32 | ]; 33 | } 34 | 35 | public function onPayloadValid(PayloadValidEvent $event): void 36 | { 37 | $communityId = $event->getCommunityId(); 38 | 39 | try { 40 | $user = $this->userProvider->loadUserByIdentifier($communityId); 41 | } catch (UserNotFoundException $e) { 42 | $this->eventDispatcher->dispatch(new FirstLoginEvent($communityId), FirstLoginEvent::NAME); 43 | 44 | return; 45 | } 46 | 47 | $this->eventDispatcher->dispatch(new AuthenticateUserEvent($user), AuthenticateUserEvent::NAME); 48 | } 49 | } -------------------------------------------------------------------------------- /Subscriber/AuthenticateUserSubscriber.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class AuthenticateUserSubscriber implements EventSubscriberInterface 17 | { 18 | public function __construct( 19 | private EventDispatcherInterface $eventDispatcher, 20 | private TokenStorageInterface $tokenStorage, 21 | private RequestStack $requestStack, 22 | ) {} 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public static function getSubscribedEvents(): array 28 | { 29 | return [ 30 | AuthenticateUserEvent::NAME => [ 31 | ['onAuthenticateUser', 10] 32 | ] 33 | ]; 34 | } 35 | 36 | public function onAuthenticateUser(AuthenticateUserEvent $event): void 37 | { 38 | $user = $event->getUser(); 39 | 40 | $token = new UsernamePasswordToken($user, 'steam', $user->getRoles()); 41 | $this->tokenStorage->setToken($token); 42 | $this->requestStack->getSession()->set('_security_steam', serialize($token)); 43 | 44 | $event = new InteractiveLoginEvent($this->requestStack->getCurrentRequest(), $token); 45 | $this->eventDispatcher->dispatch($event, 'security.interactive_login'); 46 | } 47 | } -------------------------------------------------------------------------------- /Subscriber/ValidateCallbackReceivedSubscriber.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ValidateCallbackReceivedSubscriber implements EventSubscriberInterface 16 | { 17 | const STEAM_VALIDATION_URL = 'https://steamcommunity.com/openid/login'; 18 | 19 | public function __construct( 20 | private EventDispatcherInterface $eventDispatcher, 21 | private HttpClientInterface $client 22 | ) {} 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public static function getSubscribedEvents(): array 28 | { 29 | return [ 30 | CallbackReceivedEvent::NAME => [ 31 | ['onCallbackReceived', 10] 32 | ] 33 | ]; 34 | } 35 | 36 | public function onCallbackReceived(CallbackReceivedEvent $event): void 37 | { 38 | $callback = $event->getSteamCallback(); 39 | $callback->openid_mode = 'check_authentication'; 40 | 41 | $response = $this->client->request( 42 | 'POST', 43 | self::STEAM_VALIDATION_URL, 44 | [ 45 | 'body' => (array) $callback 46 | ] 47 | ); 48 | 49 | if (false === str_contains($response->getContent(), 'is_valid:true')) { 50 | throw new InvalidCallbackPayloadException(); 51 | } 52 | 53 | $this->eventDispatcher->dispatch(new PayloadValidEvent($callback->getCommunityId()), PayloadValidEvent::NAME); 54 | } 55 | } -------------------------------------------------------------------------------- /DTO/SteamCallback.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class SteamCallback 13 | { 14 | #[Assert\NotBlank] 15 | #[Assert\EqualTo('http://specs.openid.net/auth/2.0')] 16 | public string $openid_ns; 17 | 18 | #[Assert\NotBlank] 19 | public string $openid_mode; 20 | 21 | #[Assert\NotBlank] 22 | #[Assert\EqualTo('https://steamcommunity.com/openid/login')] 23 | public string $openid_op_endpoint; 24 | 25 | #[Assert\NotBlank] 26 | #[Assert\Expression('this.openid_claimed_id === this.openid_identity')] 27 | public string $openid_claimed_id; 28 | 29 | #[Assert\NotBlank] 30 | public string $openid_identity; 31 | 32 | #[Assert\NotBlank] 33 | #[SteamAssert\MatchesLoginCallbackRoute] 34 | public string $openid_return_to; 35 | 36 | #[Assert\NotBlank] 37 | public string $openid_response_nonce; 38 | 39 | #[Assert\NotBlank] 40 | public string $openid_assoc_handle; 41 | 42 | #[Assert\NotBlank] 43 | public string $openid_signed; 44 | 45 | #[Assert\NotBlank] 46 | public $openid_sig; 47 | 48 | public function getCommunityId(): string 49 | { 50 | return str_replace('https://steamcommunity.com/openid/id/', '', $this->openid_identity); 51 | } 52 | 53 | public static function fromRequest(Request $request): self 54 | { 55 | $steamCallback = new self; 56 | $steamCallback->openid_ns = $request->get('openid_ns'); 57 | $steamCallback->openid_mode = $request->get('openid_mode'); 58 | $steamCallback->openid_op_endpoint = $request->get('openid_op_endpoint'); 59 | $steamCallback->openid_claimed_id = $request->get('openid_claimed_id'); 60 | $steamCallback->openid_identity = $request->get('openid_identity'); 61 | $steamCallback->openid_return_to = $request->get('openid_return_to'); 62 | $steamCallback->openid_response_nonce = $request->get('openid_response_nonce'); 63 | $steamCallback->openid_assoc_handle = $request->get('openid_assoc_handle'); 64 | $steamCallback->openid_signed = $request->get('openid_signed'); 65 | $steamCallback->openid_sig = $request->get('openid_sig'); 66 | 67 | return $steamCallback; 68 | } 69 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub license](https://img.shields.io/github/license/knojector/SteamAuthenticationBundle)](https://github.com/knojector/SteamAuthenticationBundle/blob/main/LICENSE) 2 | [![GitHub issues](https://img.shields.io/github/issues/knojector/SteamAuthenticationBundle)](https://github.com/knojector/SteamAuthenticationBundle/issues) 3 | [![GitHub issues](https://img.shields.io/github/issues-pr/knojector/SteamAuthenticationBundle)](https://github.com/knojector/SteamAuthenticationBundle/pulls) 4 | [![GitHub issues](https://img.shields.io/github/stars/knojector/SteamAuthenticationBundle)](https://github.com/knojector/SteamAuthenticationBundle/stargazers) 5 | ![Packagist Downloads](https://img.shields.io/packagist/dt/knojector/steam-authentication-bundle) 6 | 7 | # SteamAuthenticationBundle - Steam authentication for Symfony 8 | 9 | The SteamAuthenticationBundle provides an easy way to integrate Steams OpenID login for your application. 10 | 11 | ## Table of Contents 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [Bugs and ideas?](#bugs-and-ideas) 15 | - [Requirements](#requirements) 16 | 17 | ## Installation 18 | 19 | ### Step 1 - Install the bundle 20 | ```shell 21 | composer require knojector/steam-authentication-bundle 22 | ``` 23 | 24 | ### Step 2 - Configuration 25 | ```yaml 26 | knojector_steam_authentication: 27 | login_success_redirect: 'app.protected_route' 28 | login_failure_redirect: 'app.error_route' 29 | ``` 30 | As you can see there are only two options available for configuration. Both are very self-explanatory. The option `login_success_redirect` contains the name of the route the user should be redirected to if the login was successful. The option `login_failure_redirect` contains the route the user is redirected to if the login fails. 31 | 32 | 33 | Furthermore you have to adjust your `security.yml`. In the following snippet you can see an example with a basic configuration. There are only two important things to consider: 34 | - The firewall name must be `steam` 35 | - You can use any user provider as long as the Steam CommunityId is the property to query for 36 | ```yaml 37 | security: 38 | providers: 39 | users: 40 | entity: 41 | class: 'App\Entity\User' 42 | property: 'username' 43 | firewalls: 44 | dev: 45 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 46 | security: false 47 | steam: 48 | pattern: ^/ 49 | lazy: true 50 | provider: users 51 | ``` 52 | 53 | The final step is to enable the bundle's controller in your `routes.yaml` 54 | ```yaml 55 | steam_authentication_callback: 56 | path: /steam/login_check 57 | controller: Knojector\SteamAuthenticationBundle\Controller\SteamController::callback 58 | ``` 59 | ## Usage 60 | 61 | ### Step 1 - Create your own registration subscriber 62 | Technically there is no registration available in this bundle. The bundle receives an ID from Steam and tries to load a user via the configured user provider. If no user exists you can assume that the user logged in for the first time. Instead of throwing an exception, the bundle dispatches an event you can subscribe to. A simple example for your subscriber is shown below 63 | 64 | ```php 65 | 'onFirstLogin' 87 | ]; 88 | } 89 | 90 | public function onFirstLogin(FirstLoginEvent $event) 91 | { 92 | $communityId = $event->getCommunityId(); 93 | 94 | $user = new User(); 95 | $user->setUsername($communityId); 96 | 97 | // e.g. call the Steam API to fetch more profile information 98 | // e.g. create user entity and persist it 99 | 100 | // dispatch the authenticate event in order to sign in the new created user. 101 | $this->eventDispatcher->dispatch(new AuthenticateUserEvent($user), AuthenticateUserEvent::NAME); 102 | } 103 | } 104 | ``` 105 | 106 | ### Step 2 - Place the login button 107 | To place the "Sign in through Steam" button you can include the following snippet in your template 108 | ``` 109 | {% include '@KnojectorSteamAuthentication/button.html.twig' %} 110 | ``` 111 | 112 | ## Bugs and ideas? 113 | Feel free to open an issue or submit a pull request :wink: 114 | 115 | ## Requirements 116 | The bundle requires: 117 | - PHP 8.0.0+ 118 | - Symfony 5.3/6 119 | --------------------------------------------------------------------------------