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:
parent
045bc00a85
commit
455aa67e85
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
78
inc/auth.php
78
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 ($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
|
||||
*
|
||||
|
|
|
@ -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.';
|
||||
|
|
Loading…
Reference in New Issue