diff --git a/_test/tests/inc/JWTTest.php b/_test/tests/inc/JWTTest.php new file mode 100644 index 0000000000..a767d27244 --- /dev/null +++ b/_test/tests/inc/JWTTest.php @@ -0,0 +1,82 @@ +assertFileNotExists($file); + + // initialize a new token + $jwt = JWT::fromUser('test'); + $this->assertFileExists($file); + $this->assertEquals('test', $jwt->getUser()); + $token = $jwt->getToken(); + $issued = $jwt->getIssued(); + + // validate the token + $jwt = JWT::validate($token); + $this->assertEquals('test', $jwt->getUser()); + $this->assertEquals($issued, $jwt->getIssued()); + + + // next access should get the same token + $jwt = JWT::fromUser('test'); + $this->assertEquals($token, $jwt->getToken()); + $this->assertEquals($issued, $jwt->getIssued()); + + // saving should create a new token + sleep(1); // make sure we have a new timestamp + $jwt->save(); + $this->assertNotEquals($token, $jwt->getToken()); + $this->assertNotEquals($issued, $jwt->getIssued()); + } + + public function testValidationFail() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid JWT signature'); + JWT::validate('invalid'); + } + + public function testLoadFail() + { + $jwt = JWT::fromUser('test'); + $token = $jwt->getToken(); + $file = JWT::getStorageFile('test'); + unlink($file); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('JWT not found, maybe it expired?'); + JWT::validate($token); + } + + public function testLoadExpireFail() + { + $jwt = JWT::fromUser('test'); + $token = $jwt->getToken(); + sleep(1); // make sure we have a new timestamp + $jwt->save(); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('JWT invalid, maybe it expired?'); + JWT::validate($token); + } + + public function testLogin() + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . JWT::fromUser('testuser')->getToken(); + + $this->assertArrayNotHasKey('REMOTE_USER', $_SERVER); + auth_tokenlogin(); + $this->assertEquals('testuser', $_SERVER['REMOTE_USER']); + unset($_SERVER['HTTP_AUTHORIZATION']); + } +} diff --git a/inc/Action/Authtoken.php b/inc/Action/Authtoken.php new file mode 100644 index 0000000000..5fa5f160a2 --- /dev/null +++ b/inc/Action/Authtoken.php @@ -0,0 +1,31 @@ +server->str('REMOTE_USER')); + $token->save(); + throw new ActionAbort('profile'); + } +} diff --git a/inc/JWT.php b/inc/JWT.php new file mode 100644 index 0000000000..54b5c7b568 --- /dev/null +++ b/inc/JWT.php @@ -0,0 +1,179 @@ +user = $user; + $this->issued = $issued; + } + + /** + * Load the cookiesalt as secret + * + * @return string + */ + protected static function getSecret() + { + return auth_cookiesalt(false, true); + } + + /** + * Create a new instance from a token + * + * @param $token + * @return self + * @throws \Exception + */ + public static function validate($token) + { + [$header, $payload, $signature] = sexplode('.', $token, 3, ''); + $signature = base64_decode($signature); + + if (!hash_equals($signature, hash_hmac('sha256', "$header.$payload", self::getSecret(), true))) { + throw new \Exception('Invalid JWT signature'); + } + + $header = json_decode(base64_decode($header), true); + $payload = json_decode(base64_decode($payload), true); + + if (!$header || !$payload || !$signature) { + throw new \Exception('Invalid JWT'); + } + + if ($header['alg'] !== 'HS256') { + throw new \Exception('Unsupported JWT algorithm'); + } + if ($header['typ'] !== 'JWT') { + throw new \Exception('Unsupported JWT type'); + } + if ($payload['iss'] !== 'dokuwiki') { + throw new \Exception('Unsupported JWT issuer'); + } + if (isset($payload['exp']) && $payload['exp'] < time()) { + throw new \Exception('JWT expired'); + } + + $user = $payload['sub']; + $file = self::getStorageFile($user); + if (!file_exists($file)) { + throw new \Exception('JWT not found, maybe it expired?'); + } + + if(file_get_contents($file) !== $token) { + throw new \Exception('JWT invalid, maybe it expired?'); + } + + return new self($user, $payload['iat']); + } + + /** + * Create a new instance from a user + * + * Loads an existing token if available + * + * @param $user + * @return self + */ + public static function fromUser($user) + { + $file = self::getStorageFile($user); + + if (file_exists($file)) { + try { + return self::validate(io_readFile($file)); + } catch (\Exception $ignored) { + } + } + + $token = new self($user, time()); + $token->save(); + return $token; + } + + + /** + * Get the JWT token for this instance + * + * @return string + */ + public function getToken() + { + $header = [ + 'alg' => 'HS256', + 'typ' => 'JWT', + ]; + $header = base64_encode(json_encode($header)); + $payload = [ + 'iss' => 'dokuwiki', + 'sub' => $this->user, + 'iat' => $this->issued, + ]; + $payload = base64_encode(json_encode($payload)); + $signature = hash_hmac('sha256', "$header.$payload", self::getSecret(), true); + $signature = base64_encode($signature); + return "$header.$payload.$signature"; + } + + /** + * Save the token for the user + * + * Resets the issued timestamp + */ + public function save() + { + $this->issued = time(); + io_saveFile(self::getStorageFile($this->user), $this->getToken()); + } + + /** + * Get the user of this token + * + * @return string + */ + public function getUser() + { + return $this->user; + } + + /** + * Get the issued timestamp of this token + * + * @return int + */ + public function getIssued() + { + return $this->issued; + } + + /** + * Get the storage file for this token + * + * Tokens are stored to be able to invalidate them + * + * @param string $user The user the token is for + * @return string + */ + public static function getStorageFile($user) + { + return getCacheName($user, '.token'); + } +} diff --git a/inc/Ui/UserProfile.php b/inc/Ui/UserProfile.php index c1d20c281f..dc8f6e1206 100644 --- a/inc/Ui/UserProfile.php +++ b/inc/Ui/UserProfile.php @@ -4,6 +4,7 @@ use dokuwiki\Extension\AuthPlugin; use dokuwiki\Form\Form; +use dokuwiki\JWT; /** * DokuWiki User Profile Interface @@ -15,55 +16,101 @@ class UserProfile extends Ui /** * Display the User Profile Form Panel * + * @return void * @author Andreas Gohr * - * @return void */ public function show() { - global $lang; - global $conf; - global $INPUT; - global $INFO; /** @var AuthPlugin $auth */ global $auth; + global $INFO; + global $INPUT; - // print intro - print p_locale_xhtml('updateprofile'); - print '
'; + $userinfo = [ + 'user' => $_SERVER['REMOTE_USER'], + 'name' => $INPUT->post->str('fullname', $INFO['userinfo']['name'], true), + 'mail' => $INPUT->post->str('email', $INFO['userinfo']['mail'], true), - $fullname = $INPUT->post->str('fullname', $INFO['userinfo']['name'], true); - $email = $INPUT->post->str('email', $INFO['userinfo']['mail'], true); + ]; + + echo p_locale_xhtml('updateprofile'); + echo '
'; + + echo $this->updateProfileForm($userinfo)->toHTML('UpdateProfile'); + echo $this->tokenForm($userinfo['user'])->toHTML(); + if ($auth->canDo('delUser') && actionOK('profile_delete')) { + $this->deleteProfileForm()->toHTML('ProfileDelete'); + } + + echo '
'; + } + + /** + * Add the password confirmation field to the form if configured + * + * @param Form $form + * @return void + */ + protected function addPasswordConfirmation(Form $form) + { + global $lang; + global $conf; + + if (!$conf['profileconfirm']) return; + $form->addHTML("
\n"); + $attr = ['size' => '50', 'required' => 'required']; + $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr) + ->addClass('edit'); + $input->getLabel()->attr('class', 'block'); + $form->addHTML("
\n"); + } + + /** + * Create the profile form + * + * @return Form + */ + protected function updateProfileForm($userinfo) + { + global $lang; + /** @var AuthPlugin $auth */ + global $auth; - // create the updateprofile form $form = new Form(['id' => 'dw__register']); $form->addTagOpen('div')->addClass('no'); $form->addFieldsetOpen($lang['profile']); $form->setHiddenField('do', 'profile'); $form->setHiddenField('save', '1'); - $attr = array('size' => '50', 'disabled' => 'disabled'); - $input = $form->addTextInput('login', $lang['user'])->attrs($attr)->addClass('edit') - ->val($INPUT->server->str('REMOTE_USER')); + $attr = ['size' => '50', 'disabled' => 'disabled']; + $input = $form->addTextInput('login', $lang['user']) + ->attrs($attr) + ->addClass('edit') + ->val($userinfo['user']); $input->getLabel()->attr('class', 'block'); $form->addHTML("
\n"); - $attr = array('size' => '50'); + $attr = ['size' => '50']; if (!$auth->canDo('modName')) $attr['disabled'] = 'disabled'; - $input = $form->addTextInput('fullname', $lang['fullname'])->attrs($attr)->addClass('edit') - ->val($fullname); + $input = $form->addTextInput('fullname', $lang['fullname']) + ->attrs($attr) + ->addClass('edit') + ->val($userinfo['name']); $input->getLabel()->attr('class', 'block'); $form->addHTML("
\n"); - $attr = array('type' => 'email', 'size' => '50'); + $attr = ['type' => 'email', 'size' => '50']; if (!$auth->canDo('modMail')) $attr['disabled'] = 'disabled'; - $input = $form->addTextInput('email', $lang['email'])->attrs($attr)->addClass('edit') - ->val($email); + $input = $form->addTextInput('email', $lang['email']) + ->attrs($attr) + ->addClass('edit') + ->val($userinfo['mail']); $input->getLabel()->attr('class', 'block'); $form->addHTML("
\n"); if ($auth->canDo('modPass')) { - $attr = array('size'=>'50'); + $attr = ['size' => '50']; $input = $form->addPasswordInput('newpass', $lang['newpass'])->attrs($attr)->addClass('edit'); $input->getLabel()->attr('class', 'block'); $form->addHTML("
\n"); @@ -73,13 +120,7 @@ public function show() $form->addHTML("
\n"); } - if ($conf['profileconfirm']) { - $form->addHTML("
\n"); - $attr = array('size' => '50', 'required' => 'required'); - $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)->addClass('edit'); - $input->getLabel()->attr('class', 'block'); - $form->addHTML("
\n"); - } + $this->addPasswordConfirmation($form); $form->addButton('', $lang['btn_save'])->attr('type', 'submit'); $form->addButton('', $lang['btn_reset'])->attr('type', 'reset'); @@ -87,40 +128,58 @@ public function show() $form->addFieldsetClose(); $form->addTagClose('div'); - print $form->toHTML('UpdateProfile'); + return $form; + } + /** + * Create the profile delete form + * + * @return Form + */ + protected function deleteProfileForm() + { + global $lang; - if ($auth->canDo('delUser') && actionOK('profile_delete')) { + $form = new Form(['id' => 'dw__profiledelete']); + $form->addTagOpen('div')->addClass('no'); + $form->addFieldsetOpen($lang['profdeleteuser']); + $form->setHiddenField('do', 'profile_delete'); + $form->setHiddenField('delete', '1'); - // create the profiledelete form - $form = new Form(['id' => 'dw__profiledelete']); - $form->addTagOpen('div')->addClass('no'); - $form->addFieldsetOpen($lang['profdeleteuser']); - $form->setHiddenField('do', 'profile_delete'); - $form->setHiddenField('delete', '1'); - - $form->addCheckbox('confirm_delete', $lang['profconfdelete']) - ->attrs(['required' => 'required']) - ->id('dw__confirmdelete') - ->val('1'); - - if ($conf['profileconfirm']) { - $form->addHTML("
\n"); - $attr = array('size' => '50', 'required' => 'required'); - $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr) - ->addClass('edit'); - $input->getLabel()->attr('class', 'block'); - $form->addHTML("
\n"); - } - - $form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit'); - $form->addFieldsetClose(); - $form->addTagClose('div'); - - print $form->toHTML('ProfileDelete'); - } + $form->addCheckbox('confirm_delete', $lang['profconfdelete']) + ->attrs(['required' => 'required']) + ->id('dw__confirmdelete') + ->val('1'); - print '
'; + $this->addPasswordConfirmation($form); + + $form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit'); + $form->addFieldsetClose(); + $form->addTagClose('div'); + return $form; } + /** + * Get the authentication token form + * + * @param string $user + * @return Form + */ + protected function tokenForm($user) + { + global $lang; + + $token = JWT::fromUser($user); + + $form = new Form(['id' => 'dw__profiletoken', 'action' => wl(), 'method' => 'POST']); + $form->setHiddenField('do', 'authtoken'); + $form->setHiddenField('id', 'ID'); + $form->addFieldsetOpen($lang['proftokenlegend']); + $form->addHTML('

' . $lang['proftokeninfo'] . '

'); + $form->addHTML('

' . $token->getToken() . '

'); + $form->addButton('regen', $lang['proftokengenerate']); + $form->addFieldsetClose(); + + return $form; + } } diff --git a/inc/auth.php b/inc/auth.php index 6e7510555d..5180a88cdb 100644 --- a/inc/auth.php +++ b/inc/auth.php @@ -87,21 +87,20 @@ function auth_setup() { $INPUT->set('p', stripctl($INPUT->str('p'))); } - $ok = null; - if (!is_null($auth) && $auth->canDo('external')) { - $ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r')); - } - - if ($ok === null) { - // external trust mechanism not in place, or returns no result, - // then attempt auth_login - $evdata = array( - 'user' => $INPUT->str('u'), - 'password' => $INPUT->str('p'), - 'sticky' => $INPUT->bool('r'), - 'silent' => $INPUT->bool('http_credentials') - ); - Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper'); + // do the login + if(!auth_tokenlogin()) { + if(!is_null($auth) && $auth->canDo('external')) { + // external trust mechanism in place + $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r')); + } else { + $evdata = array( + 'user' => $INPUT->str('u'), + 'password' => $INPUT->str('p'), + 'sticky' => $INPUT->bool('r'), + 'silent' => $INPUT->bool('http_credentials') + ); + trigger_event('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper'); + } } //load ACL into a global array XXX @@ -160,6 +159,53 @@ function auth_loadACL() { return $out; } +/** + * Try a token login + * + * @return bool true if token login succeeded + */ +function auth_tokenlogin() { + global $USERINFO; + global $INPUT; + /** @var DokuWiki_Auth_Plugin $auth */ + global $auth; + if(!$auth) return false; + + // see if header has token + $header = ''; + if(function_exists('apache_request_headers')) { + // Authorization headers are not in $_SERVER for mod_php + $headers = apache_request_headers(); + if(isset($headers['Authorization'])) $header = $headers['Authorization']; + } else { + $header = $INPUT->server->str('HTTP_AUTHORIZATION'); + } + if(!$header) return false; + list($type, $token) = sexplode(' ', $header, 2); + if($type !== 'Bearer') return false; + + // check token + try { + $authtoken = \dokuwiki\JWT::validate($token); + } catch (Exception $e) { + msg(hsc($e->getMessage()), -1); + return false; + } + + // fetch user info from backend + $user = $authtoken->getUser(); + $USERINFO = $auth->getUserData($user); + if(!$USERINFO) return false; + + // the code is correct, set up user + $INPUT->server->set('REMOTE_USER', $user); + $_SESSION[DOKU_COOKIE]['auth']['user'] = $user; + $_SESSION[DOKU_COOKIE]['auth']['pass'] = 'nope'; + $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO; + + return true; +} + /** * Event hook callback for AUTH_LOGIN_CHECK * diff --git a/inc/lang/en/lang.php b/inc/lang/en/lang.php index 12701bc401..4eb4d2e73d 100644 --- a/inc/lang/en/lang.php +++ b/inc/lang/en/lang.php @@ -108,6 +108,11 @@ $lang['profconfdeletemissing'] = 'Confirmation check box not ticked'; $lang['proffail'] = 'User profile was not updated.'; + +$lang['proftokenlegend'] = 'Authentication Token'; +$lang['proftokengenerate'] = 'Reset Token'; +$lang['proftokeninfo'] = 'The Authentication Token can be used to let 3rd party applications to log in and act on your behalf. Resetting the token will invalidate the old one and log out all applications that used the previous token.'; + $lang['pwdforget'] = 'Forgotten your password? Get a new one'; $lang['resendna'] = 'This wiki does not support password resending.'; $lang['resendpwd'] = 'Set new password for'; @@ -397,4 +402,4 @@ $lang['log_file_too_large'] = 'Log file too large. Previous lines skipped!'; $lang['log_file_failed_to_open'] = 'Failed to open log file.'; -$lang['log_file_failed_to_read'] = 'An error occurred while reading the log.'; \ No newline at end of file +$lang['log_file_failed_to_read'] = 'An error occurred while reading the log.';