MISP/app/Plugin/OidcAuth/Lib/Oidc.php

545 lines
20 KiB
PHP

<?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();
$this->log(null, 'Authenticate');
if (!$oidc->authenticate()) {
throw new Exception("OIDC authentication was not successful.");
}
$claims = $oidc->getVerifiedClaims();
$mispUsername = $claims->email ?? $oidc->requestUserInfo('email');
if (empty($mispUsername)) {
$sub = $claims->sub ?? 'UNKNOWN';
throw new Exception("OIDC user $sub doesn't have email address, that is required by MISP.");
}
$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, ['User.sub' => $sub]);
if (!$user) { // User by sub not found, try to find by email
$user = $this->_findUser($settings, ['User.email' => $mispUsername]);
if ($user && $user['sub'] !== null && $user['sub'] !== $sub) {
$this->log($mispUsername, "User sub doesn't match ({$user['sub']} != $sub), could not login.", LOG_ERR);
return false;
}
}
$organisationProperty = $this->getConfig('organisation_property', 'organization');
$organisationName = $claims->{$organisationProperty} ?? $this->getConfig('default_org');
$organisationUuidProperty = $this->getConfig('organisation_uuid_property', 'organization_uuid');
$organisationUuid = $claims->{$organisationUuidProperty} ?? null;
$organisationId = $this->checkOrganization($organisationName, $organisationUuid, $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($mispUsername, "Role property `$roleProperty` is missing in claims, access prohibited.", LOG_WARNING);
return false;
}
$roleId = $this->getUserRole($roles, $mispUsername);
if ($roleId === null) {
$this->log($mispUsername, 'No role was assigned, access prohibited.', LOG_WARNING);
if ($user) {
$this->block($user);
}
return false;
}
$offlineAccessEnabled = $this->getConfig('offline_access', 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 = $offlineAccessEnabled ? $oidc->getRefreshToken() : null;
if ($offlineAccessEnabled && $refreshToken === null) {
$this->log($mispUsername, 'Refresh token requested, but not provided.', LOG_WARNING);
}
$this->storeMetadata($user['id'], $claims, $refreshToken);
$this->log($mispUsername, 'Logged in.');
return $user;
}
$this->log($mispUsername, 'User not found in database.');
$time = time();
$userData = [
'email' => $mispUsername,
'org_id' => $organisationId,
'newsread' => $time,
'autoalert' => $this->User->defaultPublishAlert(),
'role_id' => $roleId,
'change_pw' => 0,
'date_created' => $time,
'sub' => $sub,
'enable_password' => false, // do not generate default password for user
];
if (!$this->User->save($userData)) {
throw new RuntimeException("Could not create user `$mispUsername` in database.");
}
$refreshToken = $offlineAccessEnabled ? $oidc->getRefreshToken() : null;
if ($offlineAccessEnabled && $refreshToken === null) {
$this->log($mispUsername, 'Refresh token requested, but not provided.', LOG_WARNING);
}
$this->storeMetadata($this->User->id, $claims, $refreshToken);
$this->log($mispUsername, "User created in database with ID {$this->User->id}");
$this->log($mispUsername, 'Logged in.');
$user = $this->_findUser($settings, ['User.id' => $this->User->id]);
if ($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');
$organisationUuidProperty = $this->getConfig('organisation_uuid_property', 'organization_uuid');
$organisationUuid = $claims->{$organisationUuidProperty} ?? null;
$organisationId = $this->checkOrganization($organisationName, $organisationUuid, $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 $orgName Organisation name or UUID
* @param string|null $orgUuid Organisation UUID
* @param string $mispUsername
* @return int
* @throws Exception
*/
private function checkOrganization($orgName, $orgUuid, $mispUsername)
{
if (empty($orgName)) {
$this->log($mispUsername, "Organisation name not provided.");
return false;
}
if ($orgUuid && !Validation::uuid($orgUuid)) {
$this->log($mispUsername, "Organisation UUID `$orgUuid` is not valid UUID.");
return false;
}
$orgNameIsUuid = Validation::uuid($orgName);
if ($orgUuid) {
$conditions = ['uuid' => strtolower($orgUuid)];
$this->log($mispUsername, "Trying to find user org by UUID `$orgUuid`.");
} else if ($orgNameIsUuid) {
$conditions = ['uuid' => strtolower($orgName)];
$this->log($mispUsername, "Trying to find user org by UUID `$orgName` provided in org name.");
} else {
$conditions = ['name' => $orgName];
$this->log($mispUsername, "Trying to find user org by name `$orgName`.");
}
$orgAux = $this->User->Organisation->find('first', [
'fields' => ['Organisation.id', 'Organisation.name'],
'conditions' => $conditions,
]);
if (empty($orgAux)) {
// Org does not exists and we don't know org name, so it is not possible to crete a new one.
if ($orgNameIsUuid) {
$this->log($mispUsername, "Could not find organisation with UUID `$orgName`.");
return false;
}
$orgId = $this->User->Organisation->createOrgFromName($orgName, 0, true, $orgUuid);
$this->log($mispUsername, "User organisation `$orgName` created with ID $orgId.");
} else {
$orgId = $orgAux['Organisation']['id'];
$this->log($mispUsername, "User organisation `$orgName` found with ID $orgId.");
// If org name in database is different, update to new name from OIDC
if ($orgUuid && $orgName != $orgAux['Organisation']['name']) {
$this->changeOrgName($orgId, $orgName);
$this->log($mispUsername, "Changed org name for #$orgId from `{$orgAux['Organisation']['name']}` to `$orgName`.");
}
}
return $orgId;
}
/**
* @param int $orgId
* @param string $newName
* @return void
* @throws Exception
*/
private function changeOrgName($orgId, $newName)
{
$result = $this->User->Organisation->save([
'id' => $orgId,
'name' => $newName,
], true, ['id', 'name']);
if (!$result) {
throw new Exception("Could not rename org with ID $orgId to `$newName`.");
}
}
/**
* @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|null $username
* @param string $message
* @param int $type
*/
private function log($username, $message, $type = LOG_INFO)
{
$log = $username ? "OIDC user `$username`" : "OIDC";
if (PHP_SAPI !== 'cli') {
$sessionId = substr(session_id(), 0, 6);
$ip = $this->User->_remoteIp();
$log .= " [$ip;$sessionId] - $message";
} else {
$log .= " - $message";
}
CakeLog::write($type, $log);
}
}