commit
99cdd2a0ad
31 changed files with 666 additions and 552 deletions
@ -0,0 +1,250 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* This controller handles action about authentication. |
||||
*/ |
||||
class FreshRSS_auth_Controller extends Minz_ActionController { |
||||
/** |
||||
* This action handles the login page. |
||||
* |
||||
* It forwards to the correct login page (form or Persona) or main page if |
||||
* the user is already connected. |
||||
*/ |
||||
public function loginAction() { |
||||
if (FreshRSS_Auth::hasAccess()) { |
||||
Minz_Request::forward(array('c' => 'index', 'a' => 'index'), true); |
||||
} |
||||
|
||||
$auth_type = Minz_Configuration::authType(); |
||||
switch ($auth_type) { |
||||
case 'form': |
||||
Minz_Request::forward(array('c' => 'auth', 'a' => 'formLogin')); |
||||
break; |
||||
case 'persona': |
||||
Minz_Request::forward(array('c' => 'auth', 'a' => 'personaLogin')); |
||||
break; |
||||
case 'http_auth': |
||||
case 'none': |
||||
// It should not happened! |
||||
Minz_Error::error(404); |
||||
default: |
||||
// TODO load plugin instead |
||||
Minz_Error::error(404); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* This action handles form login page. |
||||
* |
||||
* If this action is reached through a POST request, username and password |
||||
* are compared to login the current user. |
||||
* |
||||
* Parameters are: |
||||
* - nonce (default: false) |
||||
* - username (default: '') |
||||
* - challenge (default: '') |
||||
* - keep_logged_in (default: false) |
||||
*/ |
||||
public function formLoginAction() { |
||||
invalidateHttpCache(); |
||||
|
||||
$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js'); |
||||
Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . $file_mtime)); |
||||
|
||||
if (Minz_Request::isPost()) { |
||||
$nonce = Minz_Session::param('nonce'); |
||||
$username = Minz_Request::param('username', ''); |
||||
$challenge = Minz_Request::param('challenge', ''); |
||||
try { |
||||
$conf = new FreshRSS_Configuration($username); |
||||
} catch(Minz_Exception $e) { |
||||
// $username is not a valid user, nor the configuration file! |
||||
Minz_Log::warning('Login failure: ' . $e->getMessage()); |
||||
Minz_Request::bad(_t('invalid_login'), |
||||
array('c' => 'auth', 'a' => 'login')); |
||||
} |
||||
|
||||
$ok = FreshRSS_FormAuth::checkCredentials( |
||||
$username, $conf->passwordHash, $nonce, $challenge |
||||
); |
||||
if ($ok) { |
||||
// Set session parameter to give access to the user. |
||||
Minz_Session::_param('currentUser', $username); |
||||
Minz_Session::_param('passwordHash', $conf->passwordHash); |
||||
FreshRSS_Auth::giveAccess(); |
||||
|
||||
// Set cookie parameter if nedded. |
||||
if (Minz_Request::param('keep_logged_in')) { |
||||
FreshRSS_FormAuth::makeCookie($username, $conf->passwordHash); |
||||
} else { |
||||
FreshRSS_FormAuth::deleteCookie(); |
||||
} |
||||
|
||||
// All is good, go back to the index. |
||||
Minz_Request::good(_t('login'), |
||||
array('c' => 'index', 'a' => 'index')); |
||||
} else { |
||||
Minz_Log::warning('Password mismatch for' . |
||||
' user=' . $username . |
||||
', nonce=' . $nonce . |
||||
', c=' . $challenge); |
||||
Minz_Request::bad(_t('invalid_login'), |
||||
array('c' => 'auth', 'a' => 'login')); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* This action handles Persona login page. |
||||
* |
||||
* If this action is reached through a POST request, assertion from Persona |
||||
* is verificated and user connected if all is ok. |
||||
* |
||||
* Parameter is: |
||||
* - assertion (default: false) |
||||
* |
||||
* @todo: Persona system should be moved to a plugin |
||||
*/ |
||||
public function personaLoginAction() { |
||||
$this->view->res = false; |
||||
|
||||
if (Minz_Request::isPost()) { |
||||
$this->view->_useLayout(false); |
||||
|
||||
$assert = Minz_Request::param('assertion'); |
||||
$url = 'https://verifier.login.persona.org/verify'; |
||||
$params = 'assertion=' . $assert . '&audience=' . |
||||
urlencode(Minz_Url::display(null, 'php', true)); |
||||
$ch = curl_init(); |
||||
$options = array( |
||||
CURLOPT_URL => $url, |
||||
CURLOPT_RETURNTRANSFER => TRUE, |
||||
CURLOPT_POST => 2, |
||||
CURLOPT_POSTFIELDS => $params |
||||
); |
||||
curl_setopt_array($ch, $options); |
||||
$result = curl_exec($ch); |
||||
curl_close($ch); |
||||
|
||||
$res = json_decode($result, true); |
||||
|
||||
$login_ok = false; |
||||
$reason = ''; |
||||
if ($res['status'] === 'okay') { |
||||
$email = filter_var($res['email'], FILTER_VALIDATE_EMAIL); |
||||
if ($email != '') { |
||||
$persona_file = DATA_PATH . '/persona/' . $email . '.txt'; |
||||
if (($current_user = @file_get_contents($persona_file)) !== false) { |
||||
$current_user = trim($current_user); |
||||
try { |
||||
$conf = new FreshRSS_Configuration($current_user); |
||||
$login_ok = strcasecmp($email, $conf->mail_login) === 0; |
||||
} catch (Minz_Exception $e) { |
||||
//Permission denied or conf file does not exist |
||||
$reason = 'Invalid configuration for user ' . |
||||
'[' . $current_user . '] ' . $e->getMessage(); |
||||
} |
||||
} |
||||
} else { |
||||
$reason = 'Invalid email format [' . $res['email'] . ']'; |
||||
} |
||||
} else { |
||||
$reason = $res['reason']; |
||||
} |
||||
|
||||
if ($login_ok) { |
||||
Minz_Session::_param('currentUser', $current_user); |
||||
Minz_Session::_param('mail', $email); |
||||
FreshRSS_Auth::giveAccess(); |
||||
invalidateHttpCache(); |
||||
} else { |
||||
Minz_Log::error($reason); |
||||
|
||||
$res = array(); |
||||
$res['status'] = 'failure'; |
||||
$res['reason'] = _t('invalid_login'); |
||||
} |
||||
|
||||
header('Content-Type: application/json; charset=UTF-8'); |
||||
$this->view->res = $res; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* This action removes all accesses of the current user. |
||||
*/ |
||||
public function logoutAction() { |
||||
invalidateHttpCache(); |
||||
FreshRSS_Auth::removeAccess(); |
||||
Minz_Request::good(_t('disconnected'), |
||||
array('c' => 'index', 'a' => 'index')); |
||||
} |
||||
|
||||
/** |
||||
* This action resets the authentication system. |
||||
* |
||||
* After reseting, form auth is set by default. |
||||
*/ |
||||
public function resetAction() { |
||||
Minz_View::prependTitle(_t('auth_reset') . ' · '); |
||||
|
||||
Minz_View::appendScript(Minz_Url::display( |
||||
'/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js') |
||||
)); |
||||
|
||||
$this->view->no_form = false; |
||||
// Enable changement of auth only if Persona! |
||||
if (Minz_Configuration::authType() != 'persona') { |
||||
$this->view->message = array( |
||||
'status' => 'bad', |
||||
'title' => _t('damn'), |
||||
'body' => _t('auth_not_persona') |
||||
); |
||||
$this->view->no_form = true; |
||||
return; |
||||
} |
||||
|
||||
$conf = new FreshRSS_Configuration(Minz_Configuration::defaultUser()); |
||||
// Admin user must have set its master password. |
||||
if (!$conf->passwordHash) { |
||||
$this->view->message = array( |
||||
'status' => 'bad', |
||||
'title' => _t('damn'), |
||||
'body' => _t('auth_no_password_set') |
||||
); |
||||
$this->view->no_form = true; |
||||
return; |
||||
} |
||||
|
||||
invalidateHttpCache(); |
||||
|
||||
if (Minz_Request::isPost()) { |
||||
$nonce = Minz_Session::param('nonce'); |
||||
$username = Minz_Request::param('username', ''); |
||||
$challenge = Minz_Request::param('challenge', ''); |
||||
|
||||
$ok = FreshRSS_FormAuth::checkCredentials( |
||||
$username, $conf->passwordHash, $nonce, $challenge |
||||
); |
||||
|
||||
if ($ok) { |
||||
Minz_Configuration::_authType('form'); |
||||
$ok = Minz_Configuration::writeFile(); |
||||
|
||||
if ($ok) { |
||||
Minz_Request::good(_t('auth_form_set')); |
||||
} else { |
||||
Minz_Request::bad(_t('auth_form_not_set'), |
||||
array('c' => 'auth', 'a' => 'reset')); |
||||
} |
||||
} else { |
||||
Minz_Log::warning('Password mismatch for' . |
||||
' user=' . $username . |
||||
', nonce=' . $nonce . |
||||
', c=' . $challenge); |
||||
Minz_Request::bad(_t('invalid_login'), |
||||
array('c' => 'auth', 'a' => 'reset')); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,235 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* This class handles all authentication process. |
||||
*/ |
||||
class FreshRSS_Auth { |
||||
/** |
||||
* Determines if user is connected. |
||||
*/ |
||||
private static $login_ok = false; |
||||
|
||||
/** |
||||
* This method initializes authentication system. |
||||
*/ |
||||
public static function init() { |
||||
self::$login_ok = Minz_Session::param('loginOk', false); |
||||
$current_user = Minz_Session::param('currentUser', ''); |
||||
if ($current_user === '') { |
||||
$current_user = Minz_Configuration::defaultUser(); |
||||
Minz_Session::_param('currentUser', $current_user); |
||||
} |
||||
|
||||
$access_ok = self::accessControl(); |
||||
|
||||
if ($access_ok) { |
||||
self::giveAccess(); |
||||
} else { |
||||
// Be sure all accesses are removed! |
||||
self::removeAccess(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* This method checks if user is allowed to connect. |
||||
* |
||||
* Required session parameters are also set in this method (such as |
||||
* currentUser). |
||||
* |
||||
* @return boolean true if user can be connected, false else. |
||||
*/ |
||||
public static function accessControl() { |
||||
if (self::$login_ok) { |
||||
return true; |
||||
} |
||||
|
||||
switch (Minz_Configuration::authType()) { |
||||
case 'form': |
||||
$credentials = FreshRSS_FormAuth::getCredentialsFromCookie(); |
||||
$current_user = ''; |
||||
if (isset($credentials[1])) { |
||||
$current_user = trim($credentials[0]); |
||||
Minz_Session::_param('currentUser', $current_user); |
||||
Minz_Session::_param('passwordHash', trim($credentials[1])); |
||||
} |
||||
return $current_user != ''; |
||||
case 'http_auth': |
||||
$current_user = httpAuthUser(); |
||||
$login_ok = $current_user != ''; |
||||
if ($login_ok) { |
||||
Minz_Session::_param('currentUser', $current_user); |
||||
} |
||||
return $login_ok; |
||||
case 'persona': |
||||
$email = filter_var(Minz_Session::param('mail'), FILTER_VALIDATE_EMAIL); |
||||
$persona_file = DATA_PATH . '/persona/' . $email . '.txt'; |
||||
if (($current_user = @file_get_contents($persona_file)) !== false) { |
||||
$current_user = trim($current_user); |
||||
Minz_Session::_param('currentUser', $current_user); |
||||
Minz_Session::_param('mail', $email); |
||||
return true; |
||||
} |
||||
return false; |
||||
case 'none': |
||||
return true; |
||||
default: |
||||
// TODO load extension |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gives access to the current user. |
||||
*/ |
||||
public static function giveAccess() { |
||||
$current_user = Minz_Session::param('currentUser'); |
||||
try { |
||||
$conf = new FreshRSS_Configuration($current_user); |
||||
} catch(Minz_Exception $e) { |
||||
die($e->getMessage()); |
||||
} |
||||
|
||||
switch (Minz_Configuration::authType()) { |
||||
case 'form': |
||||
self::$login_ok = Minz_Session::param('passwordHash') === $conf->passwordHash; |
||||
break; |
||||
case 'http_auth': |
||||
self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0; |
||||
break; |
||||
case 'persona': |
||||
self::$login_ok = strcasecmp(Minz_Session::param('mail'), $conf->mail_login) === 0; |
||||
break; |
||||
case 'none': |
||||
self::$login_ok = true; |
||||
break; |
||||
default: |
||||
// TODO: extensions |
||||
self::$login_ok = false; |
||||
} |
||||
|
||||
Minz_Session::_param('loginOk', self::$login_ok); |
||||
} |
||||
|
||||
/** |
||||
* Returns if current user has access to the given scope. |
||||
* |
||||
* @param string $scope general (default) or admin |
||||
* @return boolean true if user has corresponding access, false else. |
||||
*/ |
||||
public static function hasAccess($scope = 'general') { |
||||
$ok = self::$login_ok; |
||||
switch ($scope) { |
||||
case 'general': |
||||
break; |
||||
case 'admin': |
||||
$ok &= Minz_Session::param('currentUser') === Minz_Configuration::defaultUser(); |
||||
break; |
||||
default: |
||||
$ok = false; |
||||
} |
||||
return $ok; |
||||
} |
||||
|
||||
/** |
||||
* Removes all accesses for the current user. |
||||
*/ |
||||
public static function removeAccess() { |
||||
Minz_Session::_param('loginOk'); |
||||
self::$login_ok = false; |
||||
Minz_Session::_param('currentUser', Minz_Configuration::defaultUser()); |
||||
|
||||
switch (Minz_Configuration::authType()) { |
||||
case 'form': |
||||
Minz_Session::_param('passwordHash'); |
||||
FreshRSS_FormAuth::deleteCookie(); |
||||
break; |
||||
case 'persona': |
||||
Minz_Session::_param('mail'); |
||||
break; |
||||
case 'http_auth': |
||||
case 'none': |
||||
// Nothing to do... |
||||
break; |
||||
default: |
||||
// TODO: extensions |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
class FreshRSS_FormAuth { |
||||
public static function checkCredentials($username, $hash, $nonce, $challenge) { |
||||
if (!ctype_alnum($username) || |
||||
!ctype_graph($challenge) || |
||||
!ctype_alnum($nonce)) { |
||||
Minz_Log::debug('Invalid credential parameters:' . |
||||
' user=' . $username . |
||||
' challenge=' . $challenge . |
||||
' nonce=' . $nonce); |
||||
return false; |
||||
} |
||||
|
||||
if (!function_exists('password_verify')) { |
||||
include_once(LIB_PATH . '/password_compat.php'); |
||||
} |
||||
|
||||
return password_verify($nonce . $hash, $challenge); |
||||
} |
||||
|
||||
public static function getCredentialsFromCookie() { |
||||
$token = Minz_Session::getLongTermCookie('FreshRSS_login'); |
||||
if (!ctype_alnum($token)) { |
||||
return array(); |
||||
} |
||||
|
||||
$token_file = DATA_PATH . '/tokens/' . $token . '.txt'; |
||||
$mtime = @filemtime($token_file); |
||||
if ($mtime + 2629744 < time()) { |
||||
// Token has expired (> 1 month) or does not exist. |
||||
// TODO: 1 month -> use a configuration instead |
||||
@unlink($token_file); |
||||
return array(); |
||||
} |
||||
|
||||
$credentials = @file_get_contents($token_file); |
||||
return $credentials === false ? array() : explode("\t", $credentials, 2); |
||||
} |
||||
|
||||
public static function makeCookie($username, $password_hash) { |
||||
do { |
||||
$token = sha1(Minz_Configuration::salt() . $username . uniqid(mt_rand(), true)); |
||||
$token_file = DATA_PATH . '/tokens/' . $token . '.txt'; |
||||
} while (file_exists($token_file)); |
||||
|
||||
if (@file_put_contents($token_file, $username . "\t" . $password_hash) === false) { |
||||
return false; |
||||
} |
||||
|
||||
$expire = time() + 2629744; //1 month //TODO: Use a configuration instead |
||||
Minz_Session::setLongTermCookie('FreshRSS_login', $token, $expire); |
||||
return $token; |
||||
} |
||||
|
||||
public static function deleteCookie() { |
||||
$token = Minz_Session::getLongTermCookie('FreshRSS_login'); |
||||
Minz_Session::deleteLongTermCookie('FreshRSS_login'); |
||||
if (ctype_alnum($token)) { |
||||
@unlink(DATA_PATH . '/tokens/' . $token . '.txt'); |
||||
} |
||||
|
||||
if (rand(0, 10) === 1) { |
||||
self::purgeTokens(); |
||||
} |
||||
} |
||||
|
||||
public static function purgeTokens() { |
||||
$oldest = time() - 2629744; // 1 month // TODO: Use a configuration instead |
||||
foreach (new DirectoryIterator(DATA_PATH . '/tokens/') as $file_info) { |
||||
// $extension = $file_info->getExtension(); doesn't work in PHP < 5.3.7 |
||||
$extension = pathinfo($file_info->getFilename(), PATHINFO_EXTENSION); |
||||
if ($extension === 'txt' && $file_info->getMTime() < $oldest) { |
||||
@unlink($file_info->getPathname()); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,24 @@ |
||||
<?php if ($this->res === false) { ?> |
||||
<div class="prompt"> |
||||
<h1><?php echo _t('login'); ?></h1>
|
||||
|
||||
<p> |
||||
<a class="signin btn btn-important" href="<?php echo _url('auth', 'login'); ?>">
|
||||
<?php echo _i('login'); ?> <?php echo _t('login_with_persona'); ?> |
||||
</a> |
||||
|
||||
<br /><br /> |
||||
|
||||
<?php echo _i('help'); ?> |
||||
<small> |
||||
<a href="<?php echo _url('auth', 'reset'); ?>"><?php echo _t('login_persona_problem'); ?></a>
|
||||
</small> |
||||
</p> |
||||
|
||||
<p><a href="<?php echo _url('index', 'about'); ?>"><?php echo _t('about_freshrss'); ?></a></p>
|
||||
</div> |
||||
<?php |
||||
} else { |
||||
echo json_encode($this->res); |
||||
} |
||||
?> |
@ -1 +0,0 @@ |
||||
<?php print_r($this->res); ?> |
@ -1 +0,0 @@ |
||||
OK |
@ -0,0 +1,76 @@ |
||||
"use strict"; |
||||
|
||||
function init_persona() { |
||||
if (!(navigator.id && window.$)) { |
||||
if (window.console) { |
||||
console.log('FreshRSS (Persona) waiting for JS…'); |
||||
} |
||||
window.setTimeout(init_persona, 100); |
||||
return; |
||||
} |
||||
|
||||
$('a.signin').click(function() { |
||||
navigator.id.request(); |
||||
return false; |
||||
}); |
||||
|
||||
$('a.signout').click(function() { |
||||
navigator.id.logout(); |
||||
return false; |
||||
}); |
||||
|
||||
navigator.id.watch({ |
||||
loggedInUser: context['current_user_mail'], |
||||
|
||||
onlogin: function(assertion) { |
||||
// A user has logged in! Here you need to:
|
||||
// 1. Send the assertion to your backend for verification and to create a session.
|
||||
// 2. Update your UI.
|
||||
$.ajax ({ |
||||
type: 'POST', |
||||
url: url['login'], |
||||
data: {assertion: assertion}, |
||||
success: function(res, status, xhr) { |
||||
if (res.status === 'failure') { |
||||
openNotification(res.reason, 'bad'); |
||||
} else if (res.status === 'okay') { |
||||
location.href = url['index']; |
||||
} |
||||
}, |
||||
error: function(res, status, xhr) { |
||||
// alert(res);
|
||||
} |
||||
}); |
||||
}, |
||||
onlogout: function() { |
||||
// A user has logged out! Here you need to:
|
||||
// Tear down the user's session by redirecting the user or making a call to your backend.
|
||||
// Also, make sure loggedInUser will get set to null on the next page load.
|
||||
// (That's a literal JavaScript null. Not false, 0, or undefined. null.)
|
||||
$.ajax ({ |
||||
type: 'POST', |
||||
url: url['logout'], |
||||
success: function(res, status, xhr) { |
||||
location.href = url['index']; |
||||
}, |
||||
error: function(res, status, xhr) { |
||||
// alert(res);
|
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if (document.readyState && document.readyState !== 'loading') { |
||||
if (window.console) { |
||||
console.log('FreshRSS (Persona) immediate init…'); |
||||
} |
||||
init_persona(); |
||||
} else if (document.addEventListener) { |
||||
document.addEventListener('DOMContentLoaded', function () { |
||||
if (window.console) { |
||||
console.log('FreshRSS (Persona) waiting for DOMContentLoaded…'); |
||||
} |
||||
init_persona(); |
||||
}, false); |
||||
} |
Loading…
Reference in new issue