Merge pull request #8144 from JakubOnderka/oidc-check-validity

new: [oidc] Check user validity
pull/8159/head
Jakub Onderka 2022-02-19 18:24:17 +01:00 committed by GitHub
commit 83d2dcb64f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 650 additions and 336 deletions

View File

@ -46,6 +46,15 @@ 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],
'update' => ['help' => __('Update user role or organisation.'), 'boolean' => true],
],
]
]);
$parser->addSubcommand('change_pw', [
'help' => __('Change user password.'),
'parser' => [
@ -237,6 +246,44 @@ class UserShell extends AppShell
$this->out("User $userId unblocked.");
}
public function check_validity()
{
$auth = Configure::read('Security.auth');
if (!$auth) {
$this->error('External authentication is not enabled');
}
if (!is_array($auth)) {
throw new Exception("`Security.auth` config value must be array.");
}
if (!in_array('OidcAuth.Oidc', $auth, true)) {
$this->error('This method is currently supported just by OIDC auth provider');
}
App::uses('Oidc', 'OidcAuth.Lib');
$oidc = new Oidc($this->User);
$users = $this->User->find('all', [
'recursive' => -1,
'contain' => ['UserSetting'],
'conditions' => ['disabled' => false], // fetch just not disabled users
]);
$blockInvalid = $this->params['block_invalid'];
$update = $this->params['update'];
foreach ($users as $user) {
$user['User']['UserSetting'] = $user['UserSetting'];
$user = $user['User'];
if ($blockInvalid) {
$result = $oidc->blockInvalidUser($user, true, $update);
} else {
$result = $oidc->isUserValid($user, true, $update);
}
$this->out("{$user['email']}: " . ($result ? '<success>valid</success>' : '<error>invalid</error>'));
}
}
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

@ -991,6 +991,10 @@ class EventsController extends AppController
private function __attachInfoToEvents(array $columns, array $events)
{
if (empty($events)) {
return [];
}
$user = $this->Auth->user();
if (in_array('tags', $columns, true) || in_array('clusters', $columns, true)) {

View File

@ -97,6 +97,11 @@ class UserSettingsController extends AppController
);
}
}
// Do not show internal settings
if (!$this->_isSiteAdmin()) {
$conditions['AND'][] = ['NOT' => ['UserSetting.setting' => $this->UserSetting->getInternalSettingNames()]];
}
if ($this->_isRest()) {
$params = array(
'conditions' => $conditions
@ -127,9 +132,12 @@ class UserSettingsController extends AppController
public function view($id)
{
if (!$this->_isRest()) {
throw new BadRequestException("This endpoint is accessible just by REST requests.");
}
// check if the ID is valid and whether a user setting with the given ID exists
if (empty($id) || !is_numeric($id)) {
throw new InvalidArgumentException(__('Invalid ID passed.'));
throw new BadRequestException(__('Invalid ID passed.'));
}
$userSetting = $this->UserSetting->find('first', array(
'recursive' => -1,
@ -145,18 +153,14 @@ class UserSettingsController extends AppController
if (!$checkAccess) {
throw new NotFoundException(__('Invalid user setting.'));
}
if ($this->_isRest()) {
unset($userSetting['User']);
return $this->RestResponse->viewData($userSetting, $this->response->type());
} else {
$this->set($data, $userSetting);
}
unset($userSetting['User']);
return $this->RestResponse->viewData($userSetting, $this->response->type());
}
public function setSetting($user_id = false, $setting = false)
{
if (!empty($setting)) {
if (!$this->UserSetting->checkSettingValidity($setting)) {
if (!$this->UserSetting->checkSettingValidity($setting) || $this->UserSetting->isInternal($setting)) {
throw new MethodNotAllowedException(__('Invalid setting.'));
}
$settingPermCheck = $this->UserSetting->checkSettingAccess($this->Auth->user(), $setting);
@ -177,10 +181,6 @@ class UserSettingsController extends AppController
if (!empty($setting)) {
$this->request->data['UserSetting']['setting'] = $setting;
}
// force our user's ID as the user ID in all cases
$userSetting = array(
'user_id' => $this->Auth->user('id')
);
$result = $this->UserSetting->setSetting($this->Auth->user(), $this->request->data);
if ($result) {
// if we've managed to save our setting
@ -217,12 +217,10 @@ class UserSettingsController extends AppController
// load the valid settings from the model
if ($this->_isSiteAdmin()) {
$users = $this->UserSetting->User->find('list', array(
'recursive' => -1,
'fields' => array('User.id', 'User.email')
));
} else if ($this->_isAdmin()) {
$users = $this->UserSetting->User->find('list', array(
'recursive' => -1,
'conditions' => array('User.org_id' => $this->Auth->user('org_id')),
'fields' => array('User.id', 'User.email')
));
@ -234,7 +232,7 @@ class UserSettingsController extends AppController
}
$this->set('setting', $setting);
$this->set('users', $users);
$this->set('validSettings', UserSetting::VALID_SETTINGS);
$this->set('validSettings', $this->UserSetting->settingPlaceholders($this->Auth->user()));
}
}
@ -252,7 +250,7 @@ class UserSettingsController extends AppController
}
}
if (!$this->UserSetting->checkSettingValidity($setting)) {
if (!$this->UserSetting->checkSettingValidity($setting) || $this->UserSetting->isInternal($setting)) {
throw new NotFoundException(__('Invalid setting.'));
}
@ -365,7 +363,7 @@ class UserSettingsController extends AppController
'UserSetting' => array(
'user_id' => $this->Auth->user('id'),
'setting' => 'homepage',
'value' => json_encode(array('path' => $this->request->data['path']))
'value' => ['path' => $this->request->data['path']],
)
);
$result = $this->UserSetting->setSetting($this->Auth->user(), $setting);
@ -393,13 +391,13 @@ class UserSettingsController extends AppController
$hideColumns[] = $columnName;
}
$setting = array(
'UserSetting' => array(
$setting = [
'UserSetting' => [
'user_id' => $this->Auth->user()['id'],
'setting' => 'event_index_hide_columns',
'value' => json_encode($hideColumns)
)
);
'value' => $hideColumns,
]
];
$this->UserSetting->setSetting($this->Auth->user(), $setting);
return $this->RestResponse->saveSuccessResponse('UserSettings', 'eventIndexColumnToggle', false, 'json', 'Column visibility switched');
}

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,29 @@ class User extends AppModel
}
}
/**
* Check if user still valid at identity provider.
* @param array $user
* @return bool
* @throws Exception
*/
public function checkIfUserIsValid(array $user)
{
$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('Oidc', 'OidcAuth.Lib');
$oidc = new Oidc($this);
return $oidc->isUserValid($user);
}
/**
* Initialize GPG. Returns `null` if initialization failed.
*

View File

@ -68,7 +68,7 @@ class UserSetting extends AppModel
)
),
'homepage' => array(
'path' => '/events/index'
'placeholder' => ['path' => '/events/index'],
),
'default_restsearch_parameters' => array(
'placeholder' => array(
@ -98,7 +98,7 @@ class UserSetting extends AppModel
'placeholder' => ['clusters'],
],
'oidc' => [ // Data saved by OIDC plugin
'restricted' => 'perm_site_admin',
'internal' => true,
],
);
@ -134,11 +134,53 @@ class UserSetting extends AppModel
return $results;
}
/**
* @param string $setting
* @return bool
*/
public function checkSettingValidity($setting)
{
return isset(self::VALID_SETTINGS[$setting]);
}
/**
* @param string $setting
* @return bool
*/
public function isInternal($setting)
{
if (!isset(self::VALID_SETTINGS[$setting]['internal'])) {
return false;
}
return self::VALID_SETTINGS[$setting]['internal'];
}
/**
* @param array $user
* @return array
*/
public function settingPlaceholders(array $user)
{
$output = [];
foreach (self::VALID_SETTINGS as $setting => $config) {
if ($this->checkSettingAccess($user, $setting) === true) {
$output[$setting] = $config['placeholder'];
}
}
return $output;
}
public function getInternalSettingNames()
{
$internal = [];
foreach (self::VALID_SETTINGS as $setting => $config) {
if (isset($config['internal']) && $config['internal']) {
$internal[] = $setting;
}
}
return $internal;
}
/**
* @param array $user
* @param string $setting
@ -146,6 +188,9 @@ class UserSetting extends AppModel
*/
public function checkSettingAccess(array $user, $setting)
{
if ($this->isInternal($setting)) {
return 'site_admin';
}
if (!empty(self::VALID_SETTINGS[$setting]['restricted'])) {
$roleCheck = self::VALID_SETTINGS[$setting]['restricted'];
if (!is_array($roleCheck)) {
@ -164,18 +209,25 @@ class UserSetting extends AppModel
return true;
}
/*
/**
* canModify expects an auth user object or a user ID and a loaded setting as input parameters
* check if the user can modify/remove the given entry
* returns true for site admins
* returns true for org admins if setting["User"]["org_id"] === $user["org_id"]
* returns true for any user if setting["user_id"] === $user["id"]
* @param array|int $user Current user
* @param array $setting
* @param int $user_id
* @return bool
*/
public function checkAccess($user, $setting, $user_id = false)
public function checkAccess($user, array $setting, $user_id = false)
{
if (is_numeric($user)) {
$user = $this->User->getAuthUser($user);
}
if ($this->isInternal($setting['UserSetting']['setting']) && !$user['Role']['perm_site_admin']) {
return false;
}
if (empty($setting) && !empty($user_id) && is_numeric($user_id)) {
$userToCheck = $this->User->find('first', array(
'conditions' => array('User.id' => $user_id),
@ -393,7 +445,7 @@ class UserSetting extends AppModel
if (empty($userSetting['user_id'])) {
$userSetting['user_id'] = $user['id'];
}
if (empty($data['UserSetting']['setting']) || !isset($data['UserSetting']['setting'])) {
if (empty($data['UserSetting']['setting'])) {
throw new MethodNotAllowedException(__('This endpoint expects both a setting and a value to be set.'));
}
if (!$this->checkSettingValidity($data['UserSetting']['setting'])) {

View File

@ -1,5 +1,6 @@
<?php
App::uses('BaseAuthenticate', 'Controller/Component/Auth');
App::uses('Oidc', 'OidcAuth.Lib');
/**
* Config options:
@ -12,13 +13,12 @@ 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;
/**
* @param CakeRequest $request
* @param CakeResponse $response
@ -27,297 +27,8 @@ class OidcAuthenticate extends BaseAuthenticate
*/
public function authenticate(CakeRequest $request, CakeResponse $response)
{
$oidc = $this->prepareClient();
if (!$oidc->authenticate()) {
throw new Exception("OIDC authentication was not successful.");
}
$verifiedClaims = $oidc->getVerifiedClaims();
$mispUsername = isset($verifiedClaims->email) ? $verifiedClaims->email : $oidc->requestUserInfo('email');
$this->log($mispUsername, "Trying login.");
$sub = $verifiedClaims->sub;
$organisationProperty = $this->getConfig('organisation_property', 'organization');
if (property_exists($verifiedClaims, $organisationProperty)) {
$organisationName = $verifiedClaims->{$organisationProperty};
} else {
$organisationName = $this->getConfig('default_org');
}
$roles = [];
$roleProperty = $this->getConfig('roles_property', 'roles');
if (property_exists($verifiedClaims, $roleProperty)) {
$roles = $verifiedClaims->{$roleProperty};
}
if (empty($roles)) {
$roles = $oidc->requestUserInfo($roleProperty);
}
// Try to find user by `sub` field, that is unique
$this->settings['fields'] = ['username' => 'sub'];
$user = $this->_findUser($sub);
if (!$user) { // User by sub not found, try to find by email
$this->settings['fields'] = ['username' => 'email'];
$user = $this->_findUser($mispUsername);
if ($user && $user['sub'] !== null && $user['sub'] !== $sub) {
$this->log($mispUsername, "User sub doesn't match ({$user['sub']} != $sub), could not login.");
return false;
}
}
$organisationId = $this->checkOrganization($organisationName, $user, $mispUsername);
if (!$organisationId) {
return false;
}
$roleId = $this->getUserRole($roles, $mispUsername);
if ($roleId === null) {
$this->log($mispUsername, 'No role was assigned.');
return false;
}
if ($user) {
$this->log($mispUsername, "Found in database with ID {$user['id']}.");
if ($user['sub'] === null) {
$this->userModel()->updateField($user, 'sub', $sub);
$this->log($mispUsername, "User sub changed from NULL to $sub.");
$user['sub'] = $sub;
}
if ($user['email'] !== $mispUsername) {
$this->userModel()->updateField($user, 'email', $mispUsername);
$this->log($mispUsername, "User e-mail changed from {$user['email']} to $mispUsername.");
$user['email'] = $mispUsername;
}
if ($user['org_id'] != $organisationId) {
$this->userModel()->updateField($user, 'org_id', $organisationId);
$this->log($mispUsername, "User organisation changed from {$user['org_id']} to $organisationId.");
$user['org_id'] = $organisationId;
}
if ($user['role_id'] != $roleId) {
$this->userModel()->updateField($user, 'role_id', $roleId);
$this->log($mispUsername, "User role changed from {$user['role_id']} to $roleId.");
$user['role_id'] = $roleId;
}
if ($user['disabled'] && $this->getConfig('unblock', false)) {
$this->userModel()->updateField($user, 'disabled', false);
$this->log($mispUsername, "Unblocking user.");
$user['disabled'] = false;
}
$this->storeMetadata($user['id'], $verifiedClaims);
$this->log($mispUsername, 'Logged in.');
return $user;
}
$this->log($mispUsername, 'Not found in database.');
$userData = [
'email' => $mispUsername,
'org_id' => $organisationId,
'newsread' => time(),
'role_id' => $roleId,
'change_pw' => 0,
'date_created' => time(),
'sub' => $sub,
];
if (!$this->userModel()->save($userData)) {
throw new RuntimeException("Could not save user `$mispUsername` to database.");
}
$this->storeMetadata($this->userModel()->id, $verifiedClaims);
$this->log($mispUsername, "Saved in database with ID {$this->userModel()->id}");
$this->log($mispUsername, 'Logged in.');
return $this->_findUser($mispUsername);
}
/**
* @return \JakubOnderka\OpenIDConnectClient|\Jumbojett\OpenIDConnectClient
* @throws Exception
*/
private function prepareClient()
{
$providerUrl = $this->getConfig('provider_url');
if (!filter_var($providerUrl, FILTER_VALIDATE_URL)) {
throw new RuntimeException("Config option `OidcAuth.provider_url` must be valid URL.");
}
$clientId = $this->getConfig('client_id');
$clientSecret = $this->getConfig('client_secret');
$authenticationMethod = $this->getConfig('authentication_method', false);
if (class_exists("\JakubOnderka\OpenIDConnectClient")) {
$oidc = new \JakubOnderka\OpenIDConnectClient($providerUrl, $clientId, $clientSecret);
if ($authenticationMethod !== false && $authenticationMethod !== null) {
$oidc->setAuthenticationMethod($authenticationMethod);
}
} else if (class_exists("\Jumbojett\OpenIDConnectClient")) {
// OpenIDConnectClient will append well-know path, so if well-know path is already part of the url, remove it
// This is required just for Jumbojett, not for JakubOnderka
$wellKnownPosition = strpos($providerUrl, '/.well-known/');
if ($wellKnownPosition !== false) {
$providerUrl = substr($providerUrl, 0, $wellKnownPosition);
}
$oidc = new \Jumbojett\OpenIDConnectClient($providerUrl, $clientId, $clientSecret);
if ($authenticationMethod !== false && $authenticationMethod !== null) {
throw new Exception("Jumbojett OIDC implementation do not support changing authentication method, please use JakubOnderka's client");
}
} else {
throw new Exception("OpenID connect client is not installed.");
}
$ccm = $this->getConfig('code_challenge_method', false);
if ($ccm) {
$oidc->setCodeChallengeMethod($ccm);
}
$oidc->setRedirectURL(Configure::read('MISP.baseurl') . '/users/login');
return $oidc;
}
/**
* @param string $org
* @param array|null $user
* @param string $mispUsername
* @return int
* @throws Exception
*/
private function checkOrganization($org, $user, $mispUsername)
{
if (empty($org)) {
$this->log($mispUsername, "Organisation name not provided.");
return false;
}
$orgIsUuid = Validation::uuid($org);
$orgAux = $this->userModel()->Organisation->find('first', [
'fields' => ['Organisation.id'],
'conditions' => $orgIsUuid ? ['uuid' => mb_strtolower($org)] : ['name' => $org],
]);
if (empty($orgAux)) {
if ($orgIsUuid) {
$this->log($mispUsername, "Could not found organisation with UUID `$org`.");
return false;
}
$orgUserId = 1; // By default created by the admin
if ($user) {
$orgUserId = $user['id'];
}
$orgId = $this->userModel()->Organisation->createOrgFromName($org, $orgUserId, true);
$this->log($mispUsername, "User organisation `$org` created with ID $orgId.");
} else {
$orgId = $orgAux['Organisation']['id'];
$this->log($mispUsername, "User organisation `$org` found with ID $orgId.");
}
return $orgId;
}
/**
* @param array $roles Role list provided by OIDC
* @param string $mispUsername
* @return int|null Role ID or null if no role matches
*/
private function getUserRole(array $roles, $mispUsername)
{
$this->log($mispUsername, 'Provided roles: ' . implode(', ', $roles));
$roleMapper = $this->getConfig('role_mapper');
if (!is_array($roleMapper)) {
throw new RuntimeException("Config option `OidcAuth.role_mapper` must be array.");
}
$roleNameToId = $this->userModel()->Role->find('list', [
'fields' => ['Role.name', 'Role.id'],
]);
$roleNameToId = array_change_key_case($roleNameToId); // normalize role names to lowercase
foreach ($roleMapper as $oidcRole => $mispRole) {
if (in_array($oidcRole, $roles, true)) {
if (!is_numeric($mispRole)) {
$mispRole = mb_strtolower($mispRole);
if (isset($roleNameToId[$mispRole])) {
$mispRole = $roleNameToId[$mispRole];
} else {
$this->log($mispUsername, "MISP Role with name `$mispRole` not found, skipping.");
continue;
}
}
return $mispRole; // first match wins
}
}
return null;
}
/**
* @param string $config
* @param mixed|null $default
* @return mixed
*/
private function getConfig($config, $default = null)
{
$value = Configure::read("OidcAuth.$config");
if (empty($value)) {
if ($default === null) {
throw new RuntimeException("Config option `OidcAuth.$config` is not set.");
}
return $default;
}
return $value;
}
/**
* @param int $userId
* @param stdClass $verifiedClaims
* @return array|bool|mixed|null
* @throws Exception
*/
private function storeMetadata($userId, \stdClass $verifiedClaims)
{
// OIDC session ID
if (isset($verifiedClaims->sid)) {
CakeSession::write('oidc_sid', $verifiedClaims->sid);
}
$value = [];
foreach (['preferred_username', 'given_name', 'family_name'] as $field) {
if (property_exists($verifiedClaims, $field)) {
$value[$field] = $verifiedClaims->{$field};
}
}
return $this->userModel()->UserSetting->setSettingInternal($userId, 'oidc', $value);
}
/**
* @param string $username
* @param string $message
*/
private function log($username, $message)
{
CakeLog::info("OIDC: User `$username` $message");
}
/**
* @return User
*/
private function userModel()
{
if (isset($this->userModel)) {
return $this->userModel;
}
$this->userModel = ClassRegistry::init($this->settings['userModel']);
return $this->userModel;
$userModel = ClassRegistry::init($this->settings['userModel']);
$oidc = new Oidc($userModel);
return $oidc->authenticate($this->settings);
}
}

View File

@ -0,0 +1,472 @@
<?php
class Oidc
{
private $oidcClient;
/** @var User */
private $User;
public function __construct(User $user)
{
$this->User = $user;
}
/**
* @return array|false
* @throws Exception
*/
public function authenticate(array $settings)
{
$oidc = $this->prepareClient();
if (!$oidc->authenticate()) {
throw new Exception("OIDC authentication was not successful.");
}
$claims = $oidc->getVerifiedClaims();
$mispUsername = $claims->email ?? $oidc->requestUserInfo('email');
$this->log($mispUsername, "Trying login.");
$sub = $claims->sub; // sub is required
// Try to find user by `sub` field, that is unique
$user = $this->_findUser($settings, ['sub' => $sub]);
if (!$user) { // User by sub not found, try to find by email
$user = $this->_findUser($settings, ['email' => $mispUsername]);
if ($user && $user['sub'] !== null && $user['sub'] !== $sub) {
$this->log($mispUsername, "User sub doesn't match ({$user['sub']} != $sub), could not login.");
return false;
}
}
$organisationProperty = $this->getConfig('organisation_property', 'organization');
$organisationName = $claims->{$organisationProperty} ?? $this->getConfig('default_org');
$organisationId = $this->checkOrganization($organisationName, $user, $mispUsername);
if (!$organisationId) {
if ($user) {
$this->block($user);
}
return false;
}
$roleProperty = $this->getConfig('roles_property', 'roles');
$roles = $claims->{$roleProperty} ?? $oidc->requestUserInfo($roleProperty);
if ($roles === null) {
$this->log($user['email'], "Role property `$roleProperty` is missing in claims.");
return false;
}
$roleId = $this->getUserRole($roles, $mispUsername);
if ($roleId === null) {
$this->log($mispUsername, 'No role was assigned.');
if ($user) {
$this->block($user);
}
return false;
}
if ($user) {
$this->log($mispUsername, "Found in database with ID {$user['id']}.");
if ($user['sub'] === null) {
$this->User->updateField($user, 'sub', $sub);
$this->log($mispUsername, "User sub changed from NULL to $sub.");
$user['sub'] = $sub;
}
if ($user['email'] !== $mispUsername) {
$this->User->updateField($user, 'email', $mispUsername);
$this->log($mispUsername, "User e-mail changed from {$user['email']} to $mispUsername.");
$user['email'] = $mispUsername;
}
if ($user['org_id'] != $organisationId) {
$this->User->updateField($user, 'org_id', $organisationId);
$this->log($mispUsername, "User organisation changed from {$user['org_id']} to $organisationId.");
$user['org_id'] = $organisationId;
}
if ($user['role_id'] != $roleId) {
$this->User->updateField($user, 'role_id', $roleId);
$this->log($mispUsername, "User role changed from {$user['role_id']} to $roleId.");
$user['role_id'] = $roleId;
}
if ($user['disabled'] && $this->getConfig('unblock', false)) {
$this->User->updateField($user, 'disabled', false);
$this->log($mispUsername, "Unblocking user.");
$user['disabled'] = false;
}
$refreshToken = $this->getConfig('offline_access', false) ? $oidc->getRefreshToken() : null;
$this->storeMetadata($user['id'], $claims, $refreshToken);
$this->log($mispUsername, 'Logged in.');
return $user;
}
$this->log($mispUsername, 'Not found in database.');
$userData = [
'email' => $mispUsername,
'org_id' => $organisationId,
'newsread' => time(),
'role_id' => $roleId,
'change_pw' => 0,
'date_created' => time(),
'sub' => $sub,
];
if (!$this->User->save($userData)) {
throw new RuntimeException("Could not save user `$mispUsername` to database.");
}
$refreshToken = $this->getConfig('offline_access', false) ? $oidc->getRefreshToken() : null;
$this->storeMetadata($this->User->id, $claims, $refreshToken);
$this->log($mispUsername, "Saved in database with ID {$this->User->id}");
$this->log($mispUsername, 'Logged in.');
$user = $this->_findUser($settings, ['id' => $this->User->id]);
if ($user['User']['sub'] !== $sub) { // just to be sure that we have the correct user
throw new Exception("User {$user['email']} sub doesn't match ({$user['sub']} != $sub)");
}
return $user;
}
/**
* @param array $user
* @param bool $ignoreValidityTime Ignore `check_user_validity` setting and always check if user is valid
* @param bool $update Update user role or organisation from OIDC
* @return bool True if user is still valid, false if not
* @throws Exception
*/
public function isUserValid(array $user, $ignoreValidityTime = false, $update = 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['id'])) {
throw new InvalidArgumentException("Invalid user model provided.");
}
if (empty($user['sub'])) {
return true; // user is not OIDC managed user
}
$userInfo = $this->findUserInfo($user);
if (!isset($userInfo['refresh_token'])) {
$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') {
$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;
}
$claims = $oidc->getVerifiedClaims();
if ($user['sub'] !== $claims->sub) {
throw new Exception("User {$user['email']} sub doesn't match ({$user['sub']} != $claims->sub)");
}
// Check user role
$roleProperty = $this->getConfig('roles_property', 'roles');
$roles = $claims->{$roleProperty} ?? $oidc->requestUserInfo($roleProperty);
if ($roles === null) {
$this->log($user['email'], "Role property `$roleProperty` is missing in claims.");
return false;
}
$roleId = $this->getUserRole($roles, $user['email']);
if ($roleId === null) {
$this->log($user['email'], 'No role was assigned.');
return false;
}
if ($update && $user['role_id'] != $roleId) {
$this->User->updateField($user, 'role_id', $roleId);
$this->log($user['email'], "User role changed from {$user['role_id']} to $roleId.");
}
// Check user org
$organisationProperty = $this->getConfig('organisation_property', 'organization');
$organisationName = $claims->{$organisationProperty} ?? $this->getConfig('default_org');
$organisationId = $this->checkOrganization($organisationName, $user, $user['email']);
if (!$organisationId) {
return false;
}
if ($update && $user['org_id'] != $organisationId) {
$this->User->updateField($user, 'org_id', $organisationId);
$this->log($user['email'], "User organisation changed from {$user['org_id']} to $organisationId.");
}
// Update refresh token if new token provided
if ($oidc->getRefreshToken()) {
$this->storeMetadata($user['id'], $claims, $oidc->getRefreshToken());
}
return true;
}
/**
* @param array $user
* @param bool $ignoreValidityTime
* @param bool $update Update user role or organisation
* @return bool True if user was blocked, false if not
* @throws Exception
*/
public function blockInvalidUser(array $user, $ignoreValidityTime = false, $update = false)
{
$isValid = $this->isUserValid($user, $ignoreValidityTime, $update);
if (!$isValid) {
$this->block($user);
}
return $isValid;
}
/**
* @return \JakubOnderka\OpenIDConnectClient
* @throws Exception
*/
private function prepareClient()
{
if ($this->oidcClient) {
return $this->oidcClient;
}
$providerUrl = $this->getConfig('provider_url');
$clientId = $this->getConfig('client_id');
$clientSecret = $this->getConfig('client_secret');
if (class_exists("\JakubOnderka\OpenIDConnectClient")) {
$oidc = new \JakubOnderka\OpenIDConnectClient($providerUrl, $clientId, $clientSecret);
} else if (class_exists("\Jumbojett\OpenIDConnectClient")) {
throw new Exception("Jumbojett OIDC implementation is not supported anymore, please use JakubOnderka's client");
} else {
throw new Exception("OpenID Connect client is not installed.");
}
$authenticationMethod = $this->getConfig('authentication_method', false);
if ($authenticationMethod !== false && $authenticationMethod !== null) {
$oidc->setAuthenticationMethod($authenticationMethod);
}
$ccm = $this->getConfig('code_challenge_method', false);
if ($ccm) {
$oidc->setCodeChallengeMethod($ccm);
}
if ($this->getConfig('offline_access', false)) {
$oidc->addScope('offline_access');
}
$oidc->setRedirectURL(Configure::read('MISP.baseurl') . '/users/login');
$this->oidcClient = $oidc;
return $oidc;
}
/**
* @param string $org
* @param array|null $user
* @param string $mispUsername
* @return int
* @throws Exception
*/
private function checkOrganization($org, $user, $mispUsername)
{
if (empty($org)) {
$this->log($mispUsername, "Organisation name not provided.");
return false;
}
$orgIsUuid = Validation::uuid($org);
$orgAux = $this->User->Organisation->find('first', [
'fields' => ['Organisation.id'],
'conditions' => $orgIsUuid ? ['uuid' => strtolower($org)] : ['name' => $org],
]);
if (empty($orgAux)) {
if ($orgIsUuid) {
$this->log($mispUsername, "Could not found organisation with UUID `$org`.");
return false;
}
$orgUserId = 1; // By default created by the admin
if ($user) {
$orgUserId = $user['id'];
}
$orgId = $this->User->Organisation->createOrgFromName($org, $orgUserId, true);
$this->log($mispUsername, "User organisation `$org` created with ID $orgId.");
} else {
$orgId = $orgAux['Organisation']['id'];
$this->log($mispUsername, "User organisation `$org` found with ID $orgId.");
}
return $orgId;
}
/**
* @param array $roles Role list provided by OIDC
* @param string $mispUsername
* @return int|null Role ID or null if no role matches
*/
private function getUserRole(array $roles, $mispUsername)
{
$this->log($mispUsername, 'Provided roles: ' . implode(', ', $roles));
$roleMapper = $this->getConfig('role_mapper');
if (!is_array($roleMapper)) {
throw new RuntimeException("Config option `OidcAuth.role_mapper` must be array.");
}
$roleNameToId = $this->User->Role->find('list', [
'fields' => ['Role.name', 'Role.id'],
]);
$roleNameToId = array_change_key_case($roleNameToId); // normalize role names to lowercase
foreach ($roleMapper as $oidcRole => $mispRole) {
if (in_array($oidcRole, $roles, true)) {
if (!is_numeric($mispRole)) {
$mispRole = mb_strtolower($mispRole);
if (isset($roleNameToId[$mispRole])) {
$mispRole = $roleNameToId[$mispRole];
} else {
$this->log($mispUsername, "MISP Role with name `$mispRole` not found, skipping.");
continue;
}
}
return $mispRole; // first match wins
}
}
return null;
}
/**
* @param array $settings
* @param array $conditions
* @return array|null
*/
private function _findUser(array $settings, array $conditions)
{
$result = $this->User->find('first', [
'conditions' => $conditions,
'recursive' => $settings['recursive'],
'fields' => $settings['userFields'],
'contain' => $settings['contain'],
]);
if ($result) {
$user = $result['User'];
unset($result['User']);
return array_merge($user, $result);
}
return null;
}
/**
* @param string $config
* @param mixed|null $default
* @return mixed
*/
private function getConfig($config, $default = null)
{
$value = Configure::read("OidcAuth.$config");
if (empty($value)) {
if ($default === null) {
throw new RuntimeException("Config option `OidcAuth.$config` is not set.");
}
return $default;
}
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->User->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, $refreshToken = null)
{
// OIDC session ID
if (isset($verifiedClaims->sid)) {
CakeSession::write('oidc_sid', $verifiedClaims->sid);
}
$value = [];
foreach (['preferred_username', 'given_name', 'family_name'] as $field) {
if (property_exists($verifiedClaims, $field)) {
$value[$field] = $verifiedClaims->{$field};
}
}
if ($refreshToken) {
$value['validity_check_timestamp'] = time();
$value['refresh_token'] = $refreshToken;
}
return $this->User->UserSetting->setSettingInternal($userId, 'oidc', $value);
}
/**
* @param array $user
* @return void
* @throws Exception
*/
private function block(array $user)
{
$this->User->updateField($user, 'disabled', true);
$this->log($user['email'], "User blocked by OIDC");
}
/**
* @param string $username
* @param string $message
*/
private function log($username, $message)
{
CakeLog::info("OIDC: User `$username` $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

View File

@ -39,8 +39,8 @@
echo $this->element('/genericElements/SideMenu/side_menu', array('menuList' => 'globalActions', 'menuItem' => 'user_settings_set'));
?>
<script type="text/javascript">
var validSettings = <?php echo json_encode($validSettings); ?>;
$(document).ready(function() {
var validSettings = <?= json_encode($validSettings); ?>;
$(function() {
loadUserSettingValue();
changeUserSettingPlaceholder();
$('#UserSettingSetting').on('change', function() {
@ -56,11 +56,11 @@
var user_id = $('#UserSettingUserId').val();
var setting = $('#UserSettingSetting').val();
$.ajax({
type:"get",
type: "get",
url: baseurl + "/user_settings/getSetting/" + user_id + "/" + setting,
success: function (data, textStatus) {
success: function (data) {
if (data === '[]') {
var data = '';
data = '';
} else {
data = JSON.parse(data);
data = JSON.stringify(data, undefined, 4);
@ -73,7 +73,7 @@
function changeUserSettingPlaceholder() {
var setting = $('#UserSettingSetting').val();
if (setting in validSettings) {
$('#UserSettingValue').attr("placeholder", "Example:\n" + JSON.stringify(validSettings[setting]["placeholder"], undefined, 4));
$('#UserSettingValue').attr("placeholder", "Example:\n" + JSON.stringify(validSettings[setting], undefined, 4));
}
}
</script>

View File

@ -4587,7 +4587,7 @@ function checkNoticeList(type) {
}
$(document).ready(function() {
$(function() {
// Show popover for disabled input that contains `data-disabled-reason`.
$('input:disabled[data-disabled-reason]').popover("destroy").popover({
placement: 'right',
@ -4642,7 +4642,7 @@ $(document).ready(function() {
var url = $(this).data('checkbox-url');
});
$('#setHomePage').click(function(event) {
$('#setHomePage').parent().click(function(event) {
event.preventDefault();
setHomePage();
});
@ -5209,7 +5209,7 @@ function setHomePage() {
$.ajax({
type: 'GET',
url: baseurl + '/userSettings/setHomePage',
success:function (data) {
success: function (data) {
$('#ajax_hidden_container').html(data);
var currentPage = $('#setHomePage').data('current-page');
$('#UserSettingPath').val(currentPage);