kanalumaddela/laravel-steam-login

View on GitHub
src/SteamLogin.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php
/*
 * Laravel Steam Login.
 *
 * @link      https://www.maddela.org
 * @link      https://github.com/kanalumaddela/laravel-steam-login
 *
 * @author    kanalumaddela <git@maddela.org>
 * @copyright Copyright (c) 2018-2021 Maddela
 * @license   MIT
 */

namespace kanalumaddela\LaravelSteamLogin;

use function config;
use Exception;
use function explode;
use const FILTER_VALIDATE_URL;
use function filter_var;
use function get_class;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Psr7\Response;
use function http_build_query;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use InvalidArgumentException;
use function is_numeric;
use kanalumaddela\LaravelSteamLogin\Contracts\SteamLoginInterface;
use function parse_url;
use const PHP_URL_HOST;
use function preg_match;
use function redirect;
use function sprintf;
use function strpos;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use function trigger_error;

class SteamLogin implements SteamLoginInterface
{
    /**
     * Steam OpenID URL.
     *
     * @var string
     */
    const OPENID_STEAM = 'https://steamcommunity.com/openid/login';

    /**
     * OpenID Specs.
     *
     * @var string
     */
    const OPENID_SPECS = 'http://specs.openid.net/auth/2.0';

    /**
     * Is this a Laravel application?
     *
     * @var bool
     */
    protected static $isLaravel = true;

    /**
     * Defines if app is HTTPS.
     *
     * @var bool
     */
    protected static $isHttps;

    /**
     * The app's original http scheme in case of reverse proxy with ssl e.g. Cloudflare flexible ssl.
     *
     * @var string
     */
    protected static $originalScheme;

    /**
     * Default OpenID form params.
     *
     * @var array
     */
    protected static $openIdParams = [
        'openid.ns'         => self::OPENID_SPECS,
        'openid.mode'       => 'checkid_setup',
        'openid.identity'   => self::OPENID_SPECS.'/identifier_select',
        'openid.claimed_id' => self::OPENID_SPECS.'/identifier_select',
    ];

    /**
     * Request instance.
     *
     * @var \Illuminate\Http\Request
     */
    protected $request;

    /**
     * @var \Illuminate\Routing\UrlGenerator
     */
    protected $urlGenerator;

    /**
     * Guzzle instance.
     *
     * @var \GuzzleHttp\Client
     */
    protected $guzzle;

    /**
     * Guzzle response.
     *
     * @var \GuzzleHttp\Psr7\Response
     */
    protected $response;

    /**
     * OpenID body response.
     *
     * @var string
     */
    protected $openIdResponse;

    /**
     * OpenID realm.
     *
     * @var string
     */
    protected $realm;

    /**
     * Login route to redirect to steam.
     *
     * @var string
     */
    protected $loginRoute;

    /**
     * Auth handle route for openid.return_to.
     *
     * @var string
     */
    protected $authRoute;

    /**
     * ?redirect parameter used for automatic handling to the previous page a user was on.
     *
     * @var string
     */
    protected $redirectTo;

    /**
     * Login URL to Steam.
     *
     * @var string
     */
    protected $loginUrl;

    /**
     * SteamUser instance of player details.
     *
     * @var SteamUser
     */
    protected $steamUser;

    /**
     * O(1) check for route URLs.
     *
     * @var array
     */
    private $routeChecks = [];

    /**
     * SteamLogin constructor.
     *
     * @param \Illuminate\Http\Request                          $request
     * @param \Illuminate\Contracts\Foundation\Application|null $app
     */
    public function __construct(Request $request, ?Application $app)
    {
        if (PHP_INT_SIZE !== 8) {
            trigger_error('64-bit PHP is required to convert steamids', E_USER_WARNING);
        }

        $this->request = $request;

        $app = empty($app) && function_exists('app') ? app() : $app;

        if (!is_null($app)) {
            $this->urlGenerator = $app->get('url');
        }

        static::$isLaravel = !empty($app) && strpos(get_class($app), 'Lumen') === false;
        static::$isHttps = $this->isHttps();
        static::$originalScheme = $this->request->getScheme();

        if (static::$isHttps && !$this->request->isSecure()) {
            $this->urlGenerator->forceScheme('https');
        }

        unset($request, $urlGenerator, $app);
    }

    /**
     * Check if HTTPS.
     *
     * @return bool
     */
    public function isHttps(): bool
    {
        return
            $this->request->server('HTTPS', 'off') !== 'off' ||
            $this->request->server('SERVER_PORT') == 443 ||
            $this->request->server('HTTP_X_FORWARDED_PROTO') === 'https';
    }

    public function isLaravel(): bool
    {
        return static::$isLaravel;
    }

    public function setLoginRoute(string $route): self
    {
        $this->loginRoute = $this->getRoute($route);

        return $this;
    }

    protected function getRoute(string $route): string
    {
        try {
            $route = $this->urlGenerator->route($route);
        } catch (RouteNotFoundException $exception) {
            $route = $this->urlGenerator->to($route);
        }

        if (filter_var($route, FILTER_VALIDATE_URL) === false) {
            throw new InvalidArgumentException('$route: '.$route.' is not a valid URL');
        }

        return $route;
    }

    public function setAuthRoute(string $route): SteamLogin
    {
        $this->authRoute = $this->getRoute($route);

        return $this;
    }

    /**
     * Check if login is valid.
     *
     * @throws Exception|\GuzzleHttp\Exception\GuzzleException
     *
     * @return bool
     */
    public function validated(): bool
    {
        if (!$this->validRequest()) {
            if ($this->request->has('openid_error')) {
                throw new Exception('OpenID Error: '.$this->request->input('openid_error'));
            }

            return false;
        }

        $steamid = $this->validate();

        if ($validated = !empty($steamid)) {
            $this->steamUser = new SteamUser($steamid, $this->guzzle);
        }

        return $validated;
    }

    /**
     * Check if query parameters are valid post steam login.
     *
     * @param \Illuminate\Http\Request|null $request
     *
     * @return bool
     */
    public function validRequest(?Request $request = null): bool
    {
        $params = [
            'openid_assoc_handle',
            'openid_claimed_id',
            'openid_sig',
            'openid_signed',
        ];

        return $request ? $request->filled($params) : $this->request->filled($params);
    }

    /**
     * Return the steamid if validated.
     *
     * @throws \GuzzleHttp\Exception\GuzzleException
     *
     * @return string|null
     */
    public function validate(): ?string
    {
        if (!$this->request->has('openid_signed') || $this->request->query('openid_claimed_id') !== $this->request->query('openid_identity')) {
            return null;
        }

        $params = [
            'openid.sig'    => $this->request->query('openid_sig'),
            'openid.ns'     => static::OPENID_SPECS,
            'openid.mode'   => 'check_authentication',
            'openid.signed' => $this->request->query('openid_signed'),
        ];

        foreach (explode(',', $params['openid.signed']) as $param) {
            if ($param === 'signed') {
                continue;
            }

            $params['openid.'.$param] = $this->request->query('openid_'.$param);
        }

        if (empty($this->guzzle)) {
            $this->setGuzzle(new GuzzleClient());
        }

        $this->response = $this->guzzle->post($this->request->query('openid_op_endpoint'), [
            'timeout'     => config('steam-login.timeout', 5),
            'form_params' => $params,
        ]);

        $this->openIdResponse = $result = $this->response->getBody()->getContents();

        return preg_match('#is_valid\s*:\s*true#i', $result) === 1 && preg_match('#^https?://steamcommunity.com/openid/id/([0-9]{17,25})#', $this->request->query('openid_claimed_id'), $matches) === 1 ? (is_numeric($matches[1]) ? $matches[1] : null) : null;
    }

    /**
     * Set the Guzzle instance to use.
     *
     * @param \GuzzleHttp\Client $guzzle
     *
     * @return \kanalumaddela\LaravelSteamLogin\SteamLogin
     */
    public function setGuzzle(GuzzleClient $guzzle): self
    {
        $this->guzzle = $guzzle;

        return $this;
    }

    /**
     * Redirect the user to steam's login page.
     *
     * @return RedirectResponse
     */
    public function redirectToSteam(): RedirectResponse
    {
        return redirect($this->getLoginUrl());
    }

    /**
     * Return the steam login url.
     *
     * @param bool $rebuildUrl
     *
     * @return string
     */
    public function getLoginUrl(bool $rebuildUrl = false): string
    {
        if (empty($this->loginUrl) || $rebuildUrl) {
            $this->loginUrl = $this->buildLoginUrl();
        }

        return $this->loginUrl;
    }

    /**
     * Build the login url with optional openid.return_to and ?redirect.
     *
     * @param string|null $return
     * @param string|null $redirectTo
     *
     * @throws \InvalidArgumentException
     *
     * @return string
     */
    public function buildLoginUrl(?string $return = null, ?string $redirectTo = null, bool $checkRoutes = true, bool $includeRedirectTo = true): string
    {
        if ($checkRoutes) {
            $this->prepRoutes();
        }

        if (empty($return)) {
            $return = $this->authRoute;
        }

        if ($includeRedirectTo) {
            $this->setRedirectTo($redirectTo);
        }

        $params = static::$openIdParams;
        $this->realm = $this->getRealm();

        if (parse_url($this->realm, PHP_URL_HOST) !== parse_url($return, PHP_URL_HOST)) {
            throw new InvalidArgumentException(sprintf('realm: `%s` and return_to: `%s` do not have matching hosts', $this->realm, $return));
        }

        $params['openid.realm'] = $this->realm;
        $params['openid.return_to'] = $return.(static::$isLaravel && !empty($this->redirectTo) && config('steam-login.enable_redirect_to', true) && $includeRedirectTo ? '?'.http_build_query(['redirect_to' => $this->redirectTo]) : '');

        return static::OPENID_STEAM.'?'.http_build_query($params);
    }

    protected function prepRoutes(): SteamLogin
    {
        $this->routeChecks = [];

        $this->loginRoute = $this->getRoute(config('steam-login.routes.login', 'login.steam'));
        $this->authRoute = $this->getRoute(config('steam-login.routes.auth', 'auth.steam'));

        $this->routeChecks[$this->loginRoute] = true;
        $this->routeChecks[$this->authRoute] = true;

        return $this;
    }

    /**
     * @return string
     */
    public function getRealm(): string
    {
        if (empty($this->realm)) {
            $this->setRealm();
        }

        return $this->realm;
    }

    /**
     * Set the openid.realm either by passing the URL or the domain only.
     *
     * @param string|null $realm
     *
     * @return \kanalumaddela\LaravelSteamLogin\SteamLogin
     */
    public function setRealm(?string $realm = null): self
    {
        if (empty($realm)) {
            $host = $this->request->getHttpHost();

            $realm = (static::$isHttps ? 'https' : 'http').'://'.$host;
        } elseif (!filter_var($realm, FILTER_VALIDATE_URL)) {
            throw new InvalidArgumentException('$realm: `'.$realm.'` is not a valid URL.');
        }

        $this->realm = $realm;

        return $this;
    }

    /**
     * Return the user to the page they were on before logging in
     * or home if no valid ?redirect given.
     *
     * @return RedirectResponse
     */
    public function previousPage(): RedirectResponse
    {
        return redirect($this->getRedirectTo());
    }

    /**
     * Return the ?redirect_to|redirect URL.
     *
     * @return string
     */
    public function getRedirectTo(): string
    {
        if (!empty($redirect_to = $this->request->get('redirect_to', $this->request->get('redirect')))) {
            $this->setRedirectTo($redirect_to);
        } elseif (empty($this->redirectTo)) {
            $this->setRedirectTo();
        }

        return $this->redirectTo;
    }

    /**
     * @param string|null $redirectTo
     * @param bool        $checkRoutes
     *
     * @return \kanalumaddela\LaravelSteamLogin\SteamLogin
     */
    public function setRedirectTo(string $redirectTo = null, bool $checkRoutes = true): self
    {
        if (empty($redirectTo)) {
            $redirectTo = $this->isLaravel() ? $this->urlGenerator->previous('/') : $this->urlGenerator->to('/');
        }

        if ($checkRoutes) {
            if (empty($this->routeChecks)) {
                $this->prepRoutes();
            }

            if (isset($this->routeChecks[$redirectTo])) {
                $redirectTo = $this->urlGenerator->to('/');
            } elseif (!filter_var($redirectTo, FILTER_VALIDATE_URL)) {
                throw new InvalidArgumentException('$redirectTo: `'.$redirectTo.'` is not a valid URL');
            }
        }

        $this->redirectTo = $redirectTo;

        return $this;
    }

    /**
     * @param bool $withUserInfo
     *
     * @return \kanalumaddela\LaravelSteamLogin\SteamUser
     */
    public function getPlayer(bool $withUserInfo = false): SteamUser
    {
        return $this->getSteamUser($withUserInfo);
    }

    /**
     * Return player object and optionally choose to retrieve profile info.
     *
     * @param bool $withUserInfo
     *
     * @return \kanalumaddela\LaravelSteamLogin\SteamUser
     */
    public function getSteamUser(bool $withUserInfo = false): SteamUser
    {
        return $withUserInfo ? $this->steamUser->getUserInfo() : $this->steamUser;
    }

    /**
     * Return Guzzle response of POSTing to Steam's OpenID.
     *
     * @return \GuzzleHttp\Psr7\Response
     */
    public function getResponse(): Response
    {
        return $this->response;
    }

    /**
     * Return the OpenID response.
     *
     * @return string
     */
    public function getOpenIdResponse(): ?string
    {
        return $this->openIdResponse;
    }

    /**
     * Returns Steam Login button with link.
     *
     * @param string $type
     *
     * @return string
     */
    public function loginButton(string $type = 'small'): string
    {
        return sprintf('<a href="%s" class="laravel-steam-login-button"><img src="%s" alt="Sign In Through Steam" /></a>', $this->getLoginUrl(), static::buttonImage($type));
    }

    /**
     * Return the URL of Steam Login buttons.
     *
     * @param string $type
     *
     * @return string
     */
    public static function buttonImage(string $type = 'small'): string
    {
        return 'https://steamcommunity-a.akamaihd.net/public/images/signinthroughsteam/sits_0'.($type === 'small' ? 1 : 2).'.png';
    }
}