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
This commit is contained in:
Andreas Gohr 2023-04-26 00:45:28 +02:00
parent 045bc00a85
commit 455aa67e85
5 changed files with 372 additions and 62 deletions

31
inc/Action/Authtoken.php Normal file
View File

@ -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');
}
}

163
inc/JWT.php Normal file
View File

@ -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;
}
}

View File

@ -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 = 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');
$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");
}
$this->addPasswordConfirmation($form);
$form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit');
$form->addFieldsetClose();
$form->addTagClose('div');
$form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit');
$form->addFieldsetClose();
$form->addTagClose('div');
return $form;
}
echo $form->toHTML('ProfileDelete');
}
/**
* Get the authentication token form
*
* @param string $user
* @return Form
*/
protected function tokenForm($user)
{
global $lang;
echo '</div>';
$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;
}
}

View File

@ -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 ($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 ($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');
}
}
//load ACL into a global array XXX
@ -165,6 +168,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
*

View File

@ -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.';
$lang['log_file_failed_to_read'] = 'An error occurred while reading the log.';