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