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