new: [oidc] Check user validity

pull/8144/head
Jakub Onderka 2022-02-16 17:08:31 +01:00
parent 384d517a11
commit e1774abe80
5 changed files with 188 additions and 8 deletions

View File

@ -46,6 +46,14 @@ class UserShell extends AppShell
],
],
]);
$parser->addSubcommand('check_validity', [
'help' => __('Check users validity from external identity provider and block not valid user.'),
'parser' => [
'options' => [
'block_invalid' => ['help' => __('Block user that are considered invalid.'), 'boolean' => true],
],
]
]);
$parser->addSubcommand('change_pw', [
'help' => __('Change user password.'),
'parser' => [
@ -237,6 +245,22 @@ class UserShell extends AppShell
$this->out("User $userId unblocked.");
}
public function check_validity()
{
$users = $this->User->find('all', [
'recursive' => -1,
'contain' => ['UserSetting'],
'conditions' => ['disabled' => false], // fetch just not disabled users
]);
$blockInvalid = $this->params['block_invalid'];
foreach ($users as $user) {
$user['User']['UserSetting'] = $user['UserSetting'];
$result = $this->User->checkIfUserIsValid($user['User'], $blockInvalid, true);
$this->out("{$user['User']['email']}: " . ($result ? 'valid' : 'INVALID'));
}
}
public function change_pw()
{
list($userId, $newPassword) = $this->args;

View File

@ -541,7 +541,7 @@ class AppController extends Controller
return false;
}
if ($user['disabled']) {
if ($user['disabled'] || (isset($user['logged_by_authkey']) && $user['logged_by_authkey']) && !$this->User->checkIfUserIsValid($user)) {
if ($this->_shouldLog('disabled:' . $user['id'])) {
$this->Log = ClassRegistry::init('Log');
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], 'Login attempt by disabled user.');

View File

@ -733,6 +733,9 @@ class User extends AppModel
$user['User']['Role'] = $user['Role'];
$user['User']['Organisation'] = $user['Organisation'];
$user['User']['Server'] = $user['Server'];
if (isset($user['UserSetting'])) {
$user['User']['UserSetting'] = $user['UserSetting'];
}
return $user['User'];
}
@ -820,7 +823,7 @@ class User extends AppModel
*/
public function sendEmail(array $user, $body, $bodyNoEnc = false, $subject, $replyToUser = false)
{
if ($user['User']['disabled']) {
if ($user['User']['disabled'] || !$this->checkIfUserIsValid($user['User'])) {
return true;
}
@ -1406,6 +1409,32 @@ class User extends AppModel
}
}
/**
* Check if user still valid at identity provider.
* @param array $user
* @param bool $blockInvalid Block invalid user
* @param bool $ignoreValidityTime Block invalid user
* @return bool
* @throws Exception
*/
public function checkIfUserIsValid(array $user, $blockInvalid = false, $ignoreValidityTime = false)
{
$auth = Configure::read('Security.auth');
if (!$auth) {
return true;
}
if (!is_array($auth)) {
throw new Exception("`Security.auth` config value must be array.");
}
if (!in_array('OidcAuth.Oidc', $auth, true)) {
return true; // this method currently makes sense just for OIDC auth provider
}
App::uses('OidcAuthenticate', 'OidcAuth.Controller/Component/Auth');
App::uses('ComponentCollection', 'Controller');
$oidc = new OidcAuthenticate(new ComponentCollection(), []);
return $oidc->isUserValid($user, $blockInvalid, $ignoreValidityTime);
}
/**
* Initialize GPG. Returns `null` if initialization failed.
*

View File

@ -12,13 +12,18 @@ App::uses('BaseAuthenticate', 'Controller/Component/Auth');
* - OidcAuth.organisation_property (default: `organization`)
* - OidcAuth.roles_property (default: `roles`)
* - OidcAuth.default_org
* - OidcAuth.unblock
* - OidcAuth.unblock (boolean, default: false)
* - OidcAuth.offline_access (boolean, default: false)
* - OidcAuth.check_user_validity (integer, default `0`)
*/
class OidcAuthenticate extends BaseAuthenticate
{
/** @var User|null */
private $userModel;
/** @var \JakubOnderka\OpenIDConnectClient|\Jumbojett\OpenIDConnectClient */
private $oidc;
/**
* @param CakeRequest $request
* @param CakeResponse $response
@ -70,12 +75,18 @@ class OidcAuthenticate extends BaseAuthenticate
$organisationId = $this->checkOrganization($organisationName, $user, $mispUsername);
if (!$organisationId) {
if ($user) {
$this->block($user);
}
return false;
}
$roleId = $this->getUserRole($roles, $mispUsername);
if ($roleId === null) {
$this->log($mispUsername, 'No role was assigned.');
if ($user) {
$this->block($user);
}
return false;
}
@ -111,7 +122,10 @@ class OidcAuthenticate extends BaseAuthenticate
$this->log($mispUsername, "Unblocking user.");
$user['disabled'] = false;
}
$this->storeMetadata($user['id'], $verifiedClaims);
$refreshToken = $this->getConfig('offline_access', false) ? $oidc->getRefreshToken() : null;
$this->storeMetadata($user['id'], $verifiedClaims, $refreshToken);
$this->log($mispUsername, 'Logged in.');
return $user;
}
@ -132,19 +146,91 @@ class OidcAuthenticate extends BaseAuthenticate
throw new RuntimeException("Could not save user `$mispUsername` to database.");
}
$this->storeMetadata($this->userModel()->id, $verifiedClaims);
$refreshToken = $this->getConfig('offline_access', false) ? $oidc->getRefreshToken() : null;
$this->storeMetadata($this->userModel()->id, $verifiedClaims, $refreshToken);
$this->log($mispUsername, "Saved in database with ID {$this->userModel()->id}");
$this->log($mispUsername, 'Logged in.');
return $this->_findUser($mispUsername);
}
/**
* @param array $user
* @param bool $blockInvalid Block invalid user
* @param bool $ignoreValidityTime Ignore `check_user_validity` setting and always check if user is valid
* @return bool
* @throws Exception
*/
public function isUserValid(array $user, $blockInvalid = false, $ignoreValidityTime = false)
{
if (!$this->getConfig('offline_access', false)) {
return true; // offline access is not enabled, so it is not possible to verify user
}
if (!$ignoreValidityTime) {
$checkUserValidityEvery = $this->getConfig('check_user_validity', 0);
if ($checkUserValidityEvery === 0) {
return true; // validity checking is disabled
}
}
if (empty($user['sub'])) {
return true; // user is not OIDC managed user
}
$userInfo = $this->findUserInfo($user);
if (!isset($userInfo['refresh_token'])) {
if ($blockInvalid) {
$this->block($user);
}
$this->log($user['email'], "User don't have refresh token, considering user is not valid");
return false;
}
if (!$ignoreValidityTime && $userInfo['validity_check_timestamp'] > time() - $checkUserValidityEvery) {
return true; // user was checked in last `check_user_validity`, do not check again
}
$oidc = $this->prepareClient();
try {
$oidc->refreshToken($userInfo['refresh_token']);
} catch (JakubOnderka\ErrorResponse $e) {
if ($e->getError() === 'invalid_grant') {
if ($blockInvalid) {
$this->block($user);
}
$this->log($user['email'], "Refreshing token is not possible because of `{$e->getMessage()}`, considering user is not valid");
return false;
} else {
$this->log($user['email'], "Refreshing token is not possible because of `{$e->getMessage()}`, considering user is still valid");
return true;
}
} catch (Exception $e) {
$this->log($user['email'], "Refreshing token is not possible because of `{$e->getMessage()}`, considering user is still valid");
return true;
}
// Update refresh token if new token provided
if ($oidc->getRefreshToken()) {
$userInfo['validity_check_timestamp'] = time();
$userInfo['refresh_token'] = $oidc->getRefreshToken();
$this->userModel()->UserSetting->setSettingInternal($user['id'], 'oidc', $userInfo);
}
return true;
}
/**
* @return \JakubOnderka\OpenIDConnectClient|\Jumbojett\OpenIDConnectClient
* @throws Exception
*/
private function prepareClient()
{
if ($this->oidc) {
return $this->oidc;
}
$providerUrl = $this->getConfig('provider_url');
if (!filter_var($providerUrl, FILTER_VALIDATE_URL)) {
throw new RuntimeException("Config option `OidcAuth.provider_url` must be valid URL.");
@ -180,7 +266,12 @@ class OidcAuthenticate extends BaseAuthenticate
$oidc->setCodeChallengeMethod($ccm);
}
if ($this->getConfig('offline_access', false)) {
$oidc->addScope('offline_access');
}
$oidc->setRedirectURL(Configure::read('MISP.baseurl') . '/users/login');
$this->oidc = $oidc;
return $oidc;
}
@ -276,13 +367,30 @@ class OidcAuthenticate extends BaseAuthenticate
return $value;
}
/**
* @param array $user
* @return array
*/
private function findUserInfo(array $user)
{
if (isset($user['UserSetting'])) {
foreach ($user['UserSetting'] as $userSetting) {
if ($userSetting['setting'] === 'oidc') {
return $userSetting['value'];
}
}
}
return $this->userModel()->UserSetting->getValueForUser($user['id'], 'oidc');
}
/**
* @param int $userId
* @param stdClass $verifiedClaims
* @param string|null $refreshToken
* @return array|bool|mixed|null
* @throws Exception
*/
private function storeMetadata($userId, \stdClass $verifiedClaims)
private function storeMetadata($userId, \stdClass $verifiedClaims, $refreshToken = null)
{
// OIDC session ID
if (isset($verifiedClaims->sid)) {
@ -295,10 +403,25 @@ class OidcAuthenticate extends BaseAuthenticate
$value[$field] = $verifiedClaims->{$field};
}
}
if ($refreshToken) {
$value['validity_check_timestamp'] = time();
$value['refresh_token'] = $refreshToken;
}
return $this->userModel()->UserSetting->setSettingInternal($userId, 'oidc', $value);
}
/**
* @param array $user
* @return void
* @throws Exception
*/
private function block(array $user)
{
$this->userModel()->updateField($user, 'disabled', true);
$this->log($user['email'], "User blocked by OIDC");
}
/**
* @param string $username
* @param string $message

View File

@ -1,7 +1,7 @@
# MISP OpenID Connect Authentication
This plugin provides ability to use OpenID as Single sign-on for login users to MISP.
When plugin is enabled, users are direcly redirected to SSO provider and it is not possible
When plugin is enabled, users are directly redirected to SSO provider and it is not possible
to login with passwords stored in MISP.
## Usage
@ -45,5 +45,9 @@ $config = array(
## Caveats
* When user is blocked in SSO (IdM), he/she will be not blocked in MISP. He could not log in, but users authentication keys will still work and also he/she will still receive all emails.
When user is blocked in SSO (IdM), he/she will be not blocked in MISP. He could not log in, but users authentication keys will still work and also he/she will still receive all emails.
To solve this problem:
1) set `OidcAuth.offline_access` to `true` - with that, IdP will be requested to provide offline access token
2) set `OidcAuth.check_user_validity` to number of seconds, after which user will be revalidated if he is still active in IdP. Zero means that this functionality is disabled. Recommended value is `300`.
3) because offline tokens will expire when not used, you can run `cake user check_user_validity` to check all user in one call