From 455aa67e850e236be8cd442e32eec2b8fff15fb2 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 26 Apr 2023 00:45:28 +0200 Subject: [PATCH] Introduce token authentication #2431 This generates a JWT token for users. This token can be sent in a Bearer authentication header as a login mechanism. Users can reset their token in the profile. Note: a previously suggested implementation used a custom token format, not JWT tokens --- inc/Action/Authtoken.php | 31 +++++++++ inc/JWT.php | 163 +++++++++++++++++++++++++++++++++++++++++++++++ inc/Ui/UserProfile.php | 163 ++++++++++++++++++++++++++++++++--------------- inc/auth.php | 78 +++++++++++++++++++---- inc/lang/en/lang.php | 7 +- 5 files changed, 376 insertions(+), 66 deletions(-) create mode 100644 inc/Action/Authtoken.php create mode 100644 inc/JWT.php diff --git a/inc/Action/Authtoken.php b/inc/Action/Authtoken.php new file mode 100644 index 000000000..5fa5f160a --- /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 000000000..67ec69ca1 --- /dev/null +++ b/inc/JWT.php @@ -0,0 +1,163 @@ +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 = getCacheName($user, '.token'); + if (!file_exists($file)) { + throw new \Exception('JWT not found, 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 = getCacheName($user, '.token'); + + 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(); + $file = getCacheName($this->user, '.token'); + io_saveFile($file, $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; + } +} diff --git a/inc/Ui/UserProfile.php b/inc/Ui/UserProfile.php index 90e3d4571..dc8f6e120 100644 --- a/inc/Ui/UserProfile.php +++ b/inc/Ui/UserProfile.php @@ -4,6 +4,7 @@ namespace dokuwiki\Ui; use dokuwiki\Extension\AuthPlugin; use dokuwiki\Form\Form; +use dokuwiki\JWT; /** * DokuWiki User Profile Interface @@ -21,21 +22,61 @@ class UserProfile extends Ui */ public function show() { - global $lang; - global $conf; - global $INPUT; - global $INFO; /** @var AuthPlugin $auth */ global $auth; + global $INFO; + global $INPUT; + + $userinfo = [ + 'user' => $_SERVER['REMOTE_USER'], + 'name' => $INPUT->post->str('fullname', $INFO['userinfo']['name'], true), + 'mail' => $INPUT->post->str('email', $INFO['userinfo']['mail'], true), + + ]; - // print intro echo p_locale_xhtml('updateprofile'); echo '
'; - $fullname = $INPUT->post->str('fullname', $INFO['userinfo']['name'], true); - $email = $INPUT->post->str('email', $INFO['userinfo']['mail'], true); + 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']); @@ -43,22 +84,28 @@ class UserProfile extends Ui $form->setHiddenField('save', '1'); $attr = ['size' => '50', 'disabled' => 'disabled']; - $input = $form->addTextInput('login', $lang['user'])->attrs($attr)->addClass('edit') - ->val($INPUT->server->str('REMOTE_USER')); + $input = $form->addTextInput('login', $lang['user']) + ->attrs($attr) + ->addClass('edit') + ->val($userinfo['user']); $input->getLabel()->attr('class', 'block'); $form->addHTML("
\n"); $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 = ['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"); @@ -73,13 +120,7 @@ class UserProfile extends Ui $form->addHTML("
\n"); } - if ($conf['profileconfirm']) { - $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"); - } + $this->addPasswordConfirmation($form); $form->addButton('', $lang['btn_save'])->attr('type', 'submit'); $form->addButton('', $lang['btn_reset'])->attr('type', 'reset'); @@ -87,38 +128,58 @@ class UserProfile extends Ui $form->addFieldsetClose(); $form->addTagClose('div'); - echo $form->toHTML('UpdateProfile'); + return $form; + } + /** + * Create the profile delete form + * + * @return Form + */ + protected function deleteProfileForm() + { + global $lang; - if ($auth->canDo('delUser') && actionOK('profile_delete')) { - // 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 = ['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'); - - echo $form->toHTML('ProfileDelete'); - } + $form = new Form(['id' => 'dw__profiledelete']); + $form->addTagOpen('div')->addClass('no'); + $form->addFieldsetOpen($lang['profdeleteuser']); + $form->setHiddenField('do', 'profile_delete'); + $form->setHiddenField('delete', '1'); - echo ''; + $form->addCheckbox('confirm_delete', $lang['profconfdelete']) + ->attrs(['required' => 'required']) + ->id('dw__confirmdelete') + ->val('1'); + + $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 a18eab1e3..372845359 100644 --- a/inc/auth.php +++ b/inc/auth.php @@ -91,21 +91,24 @@ function auth_setup() $INPUT->set('p', stripctl($INPUT->str('p'))); } - $ok = null; - if ($auth instanceof AuthPlugin && $auth->canDo('external')) { - $ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r')); - } + if(!auth_tokenlogin()) { + $ok = null; + + if ($auth instanceof AuthPlugin && $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 = [ - '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'); + if ($ok === null) { + // external trust mechanism not in place, or returns no result, + // then attempt auth_login + $evdata = [ + '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'); + } } //load ACL into a global array XXX @@ -166,6 +169,53 @@ function auth_loadACL() } /** + * 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 * * @param array $evdata diff --git a/inc/lang/en/lang.php b/inc/lang/en/lang.php index 12701bc40..4eb4d2e73 100644 --- a/inc/lang/en/lang.php +++ b/inc/lang/en/lang.php @@ -108,6 +108,11 @@ $lang['profconfdelete'] = 'I wish to remove my account from this wiki.