aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorAndreas Gohr <andi@splitbrain.org>2023-04-26 00:45:28 +0200
committerAndreas Gohr <andi@splitbrain.org>2023-10-12 15:21:06 +0200
commit455aa67e850e236be8cd442e32eec2b8fff15fb2 (patch)
tree9dd37926821a68e936005597a1a7cad0ae6b8e28
parent045bc00a853c571a19cc7d64f41d74a393fbfab2 (diff)
downloaddokuwiki-455aa67e850e236be8cd442e32eec2b8fff15fb2.tar.gz
dokuwiki-455aa67e850e236be8cd442e32eec2b8fff15fb2.zip
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
-rw-r--r--inc/Action/Authtoken.php31
-rw-r--r--inc/JWT.php163
-rw-r--r--inc/Ui/UserProfile.php163
-rw-r--r--inc/auth.php78
-rw-r--r--inc/lang/en/lang.php7
5 files changed, 376 insertions, 66 deletions
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 @@
+<?php
+
+namespace dokuwiki\Action;
+
+use dokuwiki\Action\Exception\ActionAbort;
+use dokuwiki\Action\Exception\ActionException;
+use dokuwiki\JWT;
+
+class Authtoken extends AbstractUserAction {
+
+ /** @inheritdoc */
+ public function minimumPermission() {
+ return AUTH_NONE;
+ }
+
+ /** @inheritdoc */
+ public function checkPreconditions() {
+ parent::checkPreconditions();
+
+ if(!checkSecurityToken()) throw new ActionException('profile');
+ }
+
+ /** @inheritdoc */
+ public function preProcess() {
+ global $INPUT;
+ parent::preProcess();
+ $token = JWT::fromUser($INPUT->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 @@
+<?php
+
+namespace dokuwiki;
+
+/**
+ * Minimal JWT implementation
+ */
+class JWT
+{
+
+ protected $user;
+ protected $issued;
+ protected $secret;
+
+ /**
+ * Create a new JWT object
+ *
+ * Use validate() or create() to create a new instance
+ *
+ * @param string $user
+ * @param int $issued
+ */
+ protected function __construct($user, $issued)
+ {
+ $this->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 '<div class="centeralign">';
- $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 '</div>';
+ }
+
+ /**
+ * 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("<br>\n");
+ $attr = ['size' => '50', 'required' => 'required'];
+ $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)
+ ->addClass('edit');
+ $input->getLabel()->attr('class', 'block');
+ $form->addHTML("<br>\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("<br>\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("<br>\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("<br>\n");
@@ -73,13 +120,7 @@ class UserProfile extends Ui
$form->addHTML("<br>\n");
}
- if ($conf['profileconfirm']) {
- $form->addHTML("<br>\n");
- $attr = ['size' => '50', 'required' => 'required'];
- $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)->addClass('edit');
- $input->getLabel()->attr('class', 'block');
- $form->addHTML("<br>\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("<br>\n");
- $attr = ['size' => '50', 'required' => 'required'];
- $input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)
- ->addClass('edit');
- $input->getLabel()->attr('class', 'block');
- $form->addHTML("<br>\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 '</div>';
+ $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('<p>' . $lang['proftokeninfo'] . '</p>');
+ $form->addHTML('<p><code style="display: block; word-break: break-word">' . $token->getToken() . '</code></p>');
+ $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. <b
$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['email_signature_text'] = 'This mail was generated by DokuWiki at
$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.';