Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
SocialLogin
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
11 / 11
46
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isLoginRequired
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 login
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 logout
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getLogoutHref
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRouters
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 parseConfig
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 getProvider
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseState
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 generateState
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace Miniframe\SocialLogin\Middleware;
4
5use Miniframe\Core\AbstractMiddleware;
6use Miniframe\Core\Config;
7use Miniframe\Core\Registry;
8use Miniframe\Core\Request;
9use Miniframe\Middleware\Session;
10use Miniframe\Response\RedirectResponse;
11use Miniframe\SocialLogin\Controller\Email;
12use Miniframe\SocialLogin\Controller\Login;
13use Miniframe\SocialLogin\Model\User;
14use Miniframe\SocialLogin\Provider\ProviderInterface;
15
16class SocialLogin extends AbstractMiddleware
17{
18    /**
19     * Base URL for the Social Login pages
20     *
21     * @var string
22     */
23    protected $socialLoginPrefixUrl;
24
25    /**
26     * Reference to the Session middleware
27     *
28     * @var Session
29     */
30    protected $session;
31
32    /**
33     * List of all enabled providers (FQCNs)
34     *
35     * @var array
36     */
37    protected $providers = array();
38
39    /**
40     * Initiates the Social Login middleware
41     *
42     * @param Request $request Reference to the Request object.
43     * @param Config  $config  Reference to the config.
44     */
45    public function __construct(Request $request, Config $config)
46    {
47        parent::__construct($request, $config);
48        $this->parseConfig();
49
50        // We require sessions to store the user login
51        if (!Registry::has(Session::class)) {
52            throw new \RuntimeException(
53                'The SocialLogin extension requires the Session middleware'
54                . ' (https://bitbucket.org/miniframe/core/src/v1/src/Middleware/Session.md)'
55            );
56        }
57        $this->session = Registry::get(Session::class);
58
59        // Determine URLs
60        $baseHref = $config->get('framework', 'base_href');
61        $this->socialLoginPrefixUrl = $baseHref . '_SOCIALLOGIN/';
62
63        // Validate the current URL, if access is granted
64        $fullPath = '/' . implode('/', $request->getPath());
65
66        if ($this->isLoginRequired($baseHref, $fullPath)) {
67            // Validate session, maybe throw a new RedirectResponse() to a login page.
68            $state = [
69                'redirectUrl' => $fullPath, // After logging in, redirect back to this URL
70            ];
71            throw new RedirectResponse(
72                $this->socialLoginPrefixUrl . 'login?state=' . rawurlencode(SocialLogin::generateState($state))
73            );
74        }
75    }
76
77    /**
78     * Verify if a login is required
79     *
80     * @param string $baseHref The base href.
81     * @param string $fullPath The full path.
82     *
83     * @return boolean
84     */
85    protected function isLoginRequired(string $baseHref, string $fullPath): bool
86    {
87        $sessionData = $this->session->get('_SOCIALLOGIN');
88
89        // Exclusions
90        $exclusionBase = $this->request->isShellRequest() ? '/' : $baseHref;
91        $excludeList = $this->config->has('sociallogin', 'exclude') ? $this->config->get('sociallogin', 'exclude') : [];
92        foreach ($excludeList as $excludePath) {
93            $regex = '/^'
94                . str_replace(['?', '\\*'], ['.', '.*?'], preg_quote($exclusionBase . ltrim($excludePath, '/'), '/'))
95                . '$/';
96            if (preg_match($regex, $fullPath)) {
97                return false;
98            }
99        }
100
101        // Requests starting with _SOCIALLOGIN are always open; they're for authentication.
102        if (substr($fullPath, 0, strlen($this->socialLoginPrefixUrl)) === $this->socialLoginPrefixUrl) {
103            return false;
104        }
105        // We're logged in, no authentication required
106        if (isset($sessionData['loggedin']) && $sessionData['loggedin'] === true) {
107            return false;
108        }
109
110        return true;
111    }
112
113    /**
114     * Logs in with a specific user
115     *
116     * @param User $user The user object.
117     *
118     * @return void
119     */
120    public function login(User $user): void
121    {
122        $sessionData = $this->session->get('_SOCIALLOGIN');
123        $sessionData['loggedin'] = true;
124        $sessionData['user'] = $user;
125        $this->session->set('_SOCIALLOGIN', $sessionData);
126    }
127
128    /**
129     * Logs out the current user
130     *
131     * @return void
132     */
133    public function logout(): void
134    {
135        $sessionData = $this->session->get('_SOCIALLOGIN');
136        $sessionData['loggedin'] = false;
137        unset($sessionData['user']);
138        $this->session->set('_SOCIALLOGIN', $sessionData);
139    }
140
141    /**
142     * Returns the user of the user that's currently logged in.
143     *
144     * @return User|null
145     */
146    public function getUser(): ?User
147    {
148        $sessionData = $this->session->get('_SOCIALLOGIN');
149        if (!isset($sessionData['loggedin']) || $sessionData['loggedin'] !== true) {
150            return null;
151        }
152        return $sessionData['user'] ?? null;
153    }
154
155    /**
156     * Returns the URL to the logout page
157     *
158     * @return string
159     */
160    public function getLogoutHref(): string
161    {
162        $state = ['redirectUrl' => '/' . implode('/', $this->request->getPath())];
163        return $this->socialLoginPrefixUrl . 'logout?state=' . rawurlencode(SocialLogin::generateState($state));
164    }
165
166    /**
167     * Adds the Social Login routes
168     *
169     * @return array
170     */
171    public function getRouters(): array
172    {
173        return [function (): ?callable {
174            $comparePath = explode('/', trim($this->socialLoginPrefixUrl, '/'));
175            foreach ($comparePath as $index => $path) {
176                if ($this->request->getPath($index) != $path) {
177                    return null;
178                }
179            }
180
181            // What's the subcommand?
182            $subCommand = $this->request->getPath(++$index);
183
184            // Email provider
185            if ($subCommand == 'email') {
186                $action = $this->request->getPath(++$index);
187                if (!method_exists(Email::class, $action)) {
188                    return null;
189                }
190                return [
191                    new Email($this->request, $this->config, $this, $this->socialLoginPrefixUrl, $this->session),
192                    $action
193                ];
194            }
195
196            // Is the subcommand valid in the Login controller?
197            if ($subCommand && !method_exists(Login::class, $subCommand)) {
198                return null;
199            }
200
201            // Is a provider specified?
202            $providerName = $this->request->getPath(++$index);
203
204            // Regular provider
205            if ($providerName) {
206                $providerFQCN = $this->getProvider($providerName);
207                $provider = new $providerFQCN($this->request, $this->config); /* @var $provider ProviderInterface */
208            }
209
210            return [
211                new Login(
212                    $this->request,
213                    $this->config,
214                    $this,
215                    $this->socialLoginPrefixUrl,
216                    $this->providers,
217                    $provider ?? null
218                ),
219                $subCommand ?? 'login'
220            ];
221        }];
222    }
223
224    /**
225     * Parses the config and populates the 'providers' array
226     *
227     * @return void
228     */
229    protected function parseConfig(): void
230    {
231        // Fetch state secret
232        static::$stateSecret = $this->config->get('sociallogin', 'state_secret');
233
234        // Fetch provider configuration
235        $providers = $this->config->get('sociallogin', 'providers');
236        if (!is_array($providers)) {
237            throw new \RuntimeException(
238                'The providers config value should be a list. (see '
239                . 'https://bitbucket.org/miniframe/miniframe-social-login/src/v1/README.md for more)'
240            );
241        }
242
243        // Do the providers exist?
244        foreach ($providers as $provider) {
245            $provider = strtolower($provider);
246            if (class_exists('App\\SocialLogin\\Provider\\' . ucfirst($provider))) {
247                $this->providers[$provider] = 'App\\SocialLogin\\Provider\\' . ucfirst($provider);
248            } elseif (class_exists('Miniframe\\SocialLogin\\Provider\\' . ucfirst($provider))) {
249                $this->providers[$provider] = 'Miniframe\\SocialLogin\\Provider\\' . ucfirst($provider);
250            } else {
251                throw new \RuntimeException(
252                    'Provider "' . $provider . '" not found in namespaces '
253                    . 'Miniframe\\SocialLogin\\Provider and'
254                    . 'App\\SocialLogin\\Provider.'
255                );
256            }
257        }
258
259        // Do the providers implement the correct interface?
260        foreach ($this->providers as $provider) {
261            if (!in_array(ProviderInterface::class, class_implements($provider))) {
262                throw new \RuntimeException($provider . ' does not implement ' . ProviderInterface::class);
263            }
264        }
265
266        // Is autologin legal?
267        if (
268            $this->config->has('sociallogin', 'autologin')
269            && $this->config->get('sociallogin', 'autologin') == true
270            && count($providers) > 1
271        ) {
272            throw new \InvalidArgumentException('Autologin can\'t be enabled when there is more then 1 provider');
273        }
274    }
275
276    /**
277     * Returns a provider fully qualified class name by its config name
278     *
279     * @param string $provider Config name of the provider.
280     *
281     * @return string
282     */
283    protected function getProvider(string $provider): string
284    {
285        $provider = strtolower($provider);
286        if (!isset($this->providers[$provider])) {
287            throw new \RuntimeException('Provider ' . $provider . ' not found');
288        }
289        return $this->providers[$provider];
290    }
291
292    /**
293     * Should contain the state secret
294     *
295     * @var string|null
296     */
297    protected static $stateSecret;
298
299    /**
300     * Parses a state string
301     *
302     * @param string|null $data The state as base64-encoded, json-encoded string.
303     *
304     * @return array
305     */
306    public static function parseState(?string $data): array
307    {
308        if (!static::$stateSecret) {
309            throw new \RuntimeException('No state secret configured. Please configure a state secret.');
310        }
311        if (!$data) {
312            return array();
313        }
314
315        // Decodes content, if it's an empty array, don't validate.
316        $decoded = json_decode(base64_decode($data), true, 2, JSON_THROW_ON_ERROR);
317        if (!$decoded) {
318            return array();
319        }
320
321        // Validate!
322        $validateArray = $decoded;
323        unset($validateArray['validate']);
324        $validateString = sha1(static::$stateSecret . serialize($validateArray));
325
326        if (!isset($decoded['validate']) || $validateString !== $decoded['validate']) {
327            throw new \UnexpectedValueException('The state looks tampered');
328        }
329
330        return $decoded;
331    }
332
333    /**
334     * Converts an array to a base64-encoded, json-encoded string.
335     *
336     * @param array $data The data array.
337     *
338     * @return string
339     */
340    public static function generateState(array $data): string
341    {
342        if (!static::$stateSecret) {
343            throw new \RuntimeException('No state secret configured. Please configure a state secret.');
344        }
345
346        $validateArray = $data;
347        if (isset($validateArray['validate'])) {
348            unset($validateArray['validate']);
349        }
350        $data['validate'] = sha1(static::$stateSecret . serialize($validateArray));
351
352        return base64_encode(json_encode($data, JSON_THROW_ON_ERROR));
353    }
354}