1<?php
2
3namespace dokuwiki\plugin\oauth;
4
5use dokuwiki\Extension\ActionPlugin;
6use OAuth\Common\Consumer\Credentials;
7use OAuth\Common\Http\Exception\TokenResponseException;
8use OAuth\Common\Storage\Exception\TokenNotFoundException;
9use OAuth\Common\Storage\Session as SessionStorage;
10use OAuth\OAuth1\Service\AbstractService as Abstract1Service;
11use OAuth\OAuth1\Token\TokenInterface;
12use OAuth\OAuth2\Service\AbstractService as Abstract2Service;
13use OAuth\OAuth2\Service\Exception\InvalidAuthorizationStateException;
14use OAuth\OAuth2\Service\Exception\MissingRefreshTokenException;
15use OAuth\ServiceFactory;
16
17/**
18 * Base class to implement a Backend Service for the oAuth Plugin
19 */
20abstract class Adapter extends ActionPlugin
21{
22    /**
23     * @var Abstract2Service|Abstract1Service
24     * @see getOAuthService() use this to ensure it's intialized
25     */
26    protected $oAuth;
27
28    // region internal methods
29
30    /**
31     * Auto register this plugin with the oAuth authentication plugin
32     *
33     * @inheritDoc
34     */
35    public function register(\Doku_Event_Handler $controller)
36    {
37        $controller->register_hook('PLUGIN_OAUTH_BACKEND_REGISTER', 'AFTER', $this, 'handleRegister');
38    }
39
40    /**
41     * Auto register this plugin with the oAuth authentication plugin
42     */
43    public function handleRegister(\Doku_Event $event, $param)
44    {
45        $event->data[$this->getServiceID()] = $this;
46    }
47
48    /**
49     * Initialize the oAuth service
50     *
51     * @param string $storageId user based storage key (if available, yet)
52     * @throws \OAuth\Common\Exception\Exception
53     */
54    public function initOAuthService($storageId = '')
55    {
56        /** @var \helper_plugin_oauth $hlp */
57        $hlp = plugin_load('helper', 'oauth');
58
59        $credentials = new Credentials(
60            $this->getKey(),
61            $this->getSecret(),
62            $hlp->redirectURI()
63        );
64
65        $serviceFactory = new ServiceFactory();
66        $serviceFactory->setHttpClient(new HTTPClient());
67        $servicename = $this->getServiceID();
68        $serviceclass = $this->registerServiceClass();
69        if ($serviceclass) {
70            $serviceFactory->registerService($servicename, $serviceclass);
71        }
72
73        if ($storageId) {
74            $storage = new Storage($storageId);
75        } else {
76            $storage = new SessionStorage();
77        }
78
79        $this->oAuth = $serviceFactory->createService(
80            $servicename,
81            $credentials,
82            $storage,
83            $this->getScopes()
84        );
85
86        if ($this->oAuth === null) {
87            throw new Exception('Failed to initialize Service ' . $this->getLabel());
88        }
89    }
90
91    /**
92     * @return Abstract2Service|Abstract1Service
93     * @throws Exception
94     */
95    public function getOAuthService()
96    {
97        if ($this->oAuth === null) throw new Exception('OAuth Service not properly initialized');
98        return $this->oAuth;
99    }
100
101    /**
102     * Once a user has been authenticated, the current token storage needs to be made permanent
103     *
104     * @param string $storageId
105     * @throws Exception
106     * @throws TokenNotFoundException
107     */
108    public function upgradeStorage($storageId)
109    {
110        $oauth = $this->getOAuthService();
111        $service = $oauth->service();
112
113        $oldStorage = $oauth->getStorage();
114        $newStorage = new Storage($storageId);
115        if ($oldStorage->hasAccessToken($service)) {
116            $newStorage->storeAccessToken($service, $oldStorage->retrieveAccessToken($service));
117        }
118        if ($oldStorage->hasAuthorizationState($service)) {
119            $newStorage->storeAuthorizationState($service, $oldStorage->retrieveAuthorizationState($service));
120        }
121
122        // fixme invalidate current oauth object? reinitialize it?
123    }
124
125    /**
126     * Refresh a possibly outdated access token
127     *
128     * Does nothing when the current token is still good to use
129     *
130     * @return void
131     * @throws MissingRefreshTokenException
132     * @throws TokenNotFoundException
133     * @throws TokenResponseException
134     * @throws Exception
135     */
136    public function refreshOutdatedToken()
137    {
138        $oauth = $this->getOAuthService();
139
140        if (!$oauth->getStorage()->hasAccessToken($oauth->service())) {
141            // no token to refresh
142            return;
143        }
144
145        $token = $oauth->getStorage()->retrieveAccessToken($oauth->service());
146        if ($token->getEndOfLife() < 0 ||
147            $token->getEndOfLife() - time() > 3600) {
148            // token is still good
149            return;
150        }
151
152        $refreshToken = $token->getRefreshToken();
153        $token = $oauth->refreshAccessToken($token);
154
155        // If the IDP did not provide a new refresh token, store the old one
156        if (!$token->getRefreshToken()) {
157            $token->setRefreshToken($refreshToken);
158            $oauth->getStorage()->storeAccessToken($oauth->service(), $token);
159        }
160    }
161
162    /**
163     * Redirects to the service for requesting access
164     *
165     * This is the first step of oAuth authentication
166     *
167     * This implementation tries to abstract away differences between oAuth1 and oAuth2,
168     * but might need to be overwritten for specific services
169     *
170     * @throws TokenResponseException
171     * @throws Exception
172     */
173    public function login()
174    {
175        $oauth = $this->getOAuthService();
176
177        // store Farmer animal in oAuth state parameter
178        /** @var \helper_plugin_farmer $farmer */
179        $farmer = plugin_load('helper', 'farmer');
180        $parameters = [];
181        if ($farmer && $animal = $farmer->getAnimal()) {
182            $parameters['state'] = urlencode(base64_encode(json_encode(
183                [
184                    'animal' => $animal,
185                    'state' => md5(rand()),
186                ]
187            )));
188            $oauth->getStorage()->storeAuthorizationState($oauth->service(), $parameters['state']);
189        }
190
191        if (is_a($oauth, Abstract1Service::class)) { /* oAuth1 handling */
192            // extra request needed for oauth1 to request a request token
193            $token = $oauth->requestRequestToken();
194            $parameters['oauth_token'] = $token->getRequestToken();
195        }
196        $url = $oauth->getAuthorizationUri($parameters);
197
198        send_redirect($url);
199    }
200
201    /**
202     * Request access token
203     *
204     * This is the second step of oAuth authentication
205     *
206     * This implementation tries to abstract away differences between oAuth1 and oAuth2,
207     * but might need to be overwritten for specific services
208     *
209     * Thrown exceptions indicate a non-successful login because of some error, appropriate messages
210     * should be shown to the user. A return of false with no exceptions indicates that there was no
211     * oauth data at all. This can probably be silently ignored.
212     *
213     * @return bool true if authentication was successful
214     * @throws \OAuth\Common\Exception\Exception
215     * @throws InvalidAuthorizationStateException
216     */
217    public function checkToken()
218    {
219        global $INPUT;
220
221        $oauth = $this->getOAuthService();
222
223        if (is_a($oauth, Abstract2Service::class)) {
224            /** @var Abstract2Service $oauth */
225            if (!$INPUT->get->has('code')) return false;
226            $state = $INPUT->get->str('state', null);
227            $accessToken = $oauth->requestAccessToken($INPUT->get->str('code'), $state);
228        } else {
229            /** @var Abstract1Service $oauth */
230            if (!$INPUT->get->has('oauth_token')) return false;
231            /** @var TokenInterface $token */
232            $token = $oauth->getStorage()->retrieveAccessToken($this->getServiceID());
233            $accessToken = $oauth->requestAccessToken(
234                $INPUT->get->str('oauth_token'),
235                $INPUT->get->str('oauth_verifier'),
236                $token->getRequestTokenSecret()
237            );
238        }
239
240        if (
241            $accessToken->getEndOfLife() !== $accessToken::EOL_NEVER_EXPIRES &&
242            !$accessToken->getRefreshToken()) {
243            msg('Service did not provide a Refresh Token. You will be logged out when the session expires.');
244        }
245
246        return true;
247    }
248
249    /**
250     * Return the Service Login Button
251     *
252     * @return string
253     */
254    public function loginButton()
255    {
256        global $ID;
257
258        $attr = buildAttributes([
259            'href' => wl($ID, array('oauthlogin' => $this->getServiceID()), false, '&'),
260            'class' => 'plugin_oauth_' . $this->getServiceID(),
261            'style' => 'background-color: ' . $this->getColor(),
262        ]);
263
264        return '<a ' . $attr . '>' . $this->getSvgLogo() . '<span>' . $this->getLabel() . '</span></a> ';
265    }
266    // endregion
267
268    // region overridable methods
269
270    /**
271     * Called on logout
272     *
273     * If there are required procedures for the service, you can implement them by overriding this.
274     *
275     * @return void
276     */
277    public function logout()
278    {
279    }
280
281    /**
282     * Retrieve the user's data via API
283     *
284     * The returned array needs to contain at least 'email', 'name', 'user' and optionally 'grps'
285     *
286     * Use the request() method of the oauth object to talk to the API
287     *
288     * @return array
289     * @throws Exception
290     * @see getOAuthService()
291     */
292    abstract public function getUser();
293
294    /**
295     * Return the scopes to request
296     *
297     * This should return the minimal scopes needed for accessing the user's data
298     *
299     * @return string[]
300     */
301    public function getScopes()
302    {
303        return [];
304    }
305
306    /**
307     * Return the user friendly name of the service
308     *
309     * Defaults to ServiceID. You may want to override this.
310     *
311     * @return string
312     */
313    public function getLabel()
314    {
315        return ucfirst($this->getServiceID());
316    }
317
318    /**
319     * Return the internal name of the Service
320     *
321     * Defaults to the plugin name (without oauth prefix). This has to match the Service class name in
322     * the appropriate lusitantian oauth Service namespace
323     *
324     * @return string
325     */
326    public function getServiceID()
327    {
328        $name = $this->getPluginName();
329        if (substr($name, 0, 5) === 'oauth') {
330            $name = substr($name, 5);
331        }
332
333        return $name;
334    }
335
336    /**
337     * Register a new Service
338     *
339     * @return string A fully qualified class name to register as new Service for your ServiceID
340     */
341    public function registerServiceClass()
342    {
343        return null;
344    }
345
346    /**
347     * Return the button color to use
348     *
349     * @return string
350     */
351    public function getColor()
352    {
353        return '#999';
354    }
355
356    /**
357     * Return the SVG of the logo for this service
358     *
359     * Defaults to a logo.svg in the plugin directory
360     *
361     * @return string
362     */
363    public function getSvgLogo()
364    {
365        $logo = DOKU_PLUGIN . $this->getPluginName() . '/logo.svg';
366        if (file_exists($logo)) return inlineSVG($logo);
367        return '';
368    }
369
370    /**
371     * The oauth key
372     *
373     * @return string
374     */
375    public function getKey()
376    {
377        return $this->getConf('key');
378    }
379
380    /**
381     * The oauth secret
382     *
383     * @return string
384     */
385    public function getSecret()
386    {
387        return $this->getConf('secret');
388    }
389
390    // endregion
391}
392