diff --git a/lam/HISTORY b/lam/HISTORY index 3fa5fb212..bb2d9bd45 100644 --- a/lam/HISTORY +++ b/lam/HISTORY @@ -5,6 +5,7 @@ March 2024 8.7 -> Cron job to deactivate inactive accounts based on lastBind overlay data (265) -> Request access: support Windows groups (266) -> Request access: usability improvements (278, 279) + -> Self service: passwordless SSO login supported for Okta and OpenID - Fixed bugs: -> User self registration creates accounts only with SSHA hash (287) diff --git a/lam/docs/manual-sources/chapter-selfService.xml b/lam/docs/manual-sources/chapter-selfService.xml index 87e96bb20..52c32de2c 100644 --- a/lam/docs/manual-sources/chapter-selfService.xml +++ b/lam/docs/manual-sources/chapter-selfService.xml @@ -202,7 +202,10 @@ server is responsible to authenticate your users. LAM will use the given user name + password for the LDAP login. To setup HTTP authentication in Apache please see this link. + url="http://httpd.apache.org/docs/2.2/howto/auth.html">link. + If you use Okta or OpenID for 2FA then you can also select to + trust the 2FA provider. In this case the user does not need to + enter any password in LAM itself (SSO). diff --git a/lam/lib/2factor.inc b/lam/lib/2factor.inc index 89a04b0ed..652eccd4a 100644 --- a/lam/lib/2factor.inc +++ b/lam/lib/2factor.inc @@ -7,6 +7,7 @@ use Duo\DuoUniversal\DuoException; use Exception; use \htmlResponsiveRow; use \LAM\LOGIN\WEBAUTHN\WebauthnManager; +use SelfServiceLoginHandler; use \selfServiceProfile; use \LAMConfig; use \htmlScript; @@ -595,9 +596,11 @@ class OktaProvider extends BaseProvider { * @see \LAM\LIB\TWO_FACTOR\BaseProvider::addCustomInput() */ public function addCustomInput(&$row, $userDn) { - $loginAttribute = $this->getLoginAttributeValue($userDn); - if (empty($loginAttribute)) { - throw new LAMException('Unable to read login attribute from ' . $userDn); + if (($this->config->loginHandler === null) || !$this->config->loginHandler->managesAuthentication()) { + $loginAttribute = $this->getLoginAttributeValue($userDn); + if (empty($loginAttribute)) { + throw new LAMException('Unable to read login attribute from ' . $userDn); + } } if ($this->verificationFailed) { return; @@ -656,10 +659,16 @@ class OktaProvider extends BaseProvider { try { $claims = json_decode(base64_decode(explode('.', $accessCode)[1]), true); logNewMessage(LOG_DEBUG, 'Okta claims: ' . print_r($claims, true)); - $loginAttribute = $this->getLoginAttributeValue($user); - if ($loginAttribute !== $claims['sub']) { - logNewMessage(LOG_ERR, 'User name ' . $loginAttribute . ' does not match claim sub: ' . $claims['sub']); - return false; + $oktaUser = $claims['sub']; + if (($this->config->loginHandler !== null) && $this->config->loginHandler->managesAuthentication()) { + $this->config->loginHandler->authorize2FaUser($oktaUser); + } + else { + $loginAttribute = $this->getLoginAttributeValue($user); + if ($loginAttribute !== $oktaUser) { + logNewMessage(LOG_ERR, 'User name ' . $loginAttribute . ' does not match claim sub: ' . $oktaUser); + return false; + } } $this->verificationFailed = false; return true; @@ -771,9 +780,12 @@ class OpenIdProvider extends BaseProvider { * @see \LAM\LIB\TWO_FACTOR\BaseProvider::addCustomInput() */ public function addCustomInput(&$row, $userDn) { - $loginAttribute = $this->getLoginAttributeValue($userDn); - if (empty($loginAttribute)) { - throw new LAMException('Unable to read login attribute from ' . $userDn); + $loginAttribute = ''; + if (($this->config->loginHandler === null) || !$this->config->loginHandler->managesAuthentication()) { + $loginAttribute = $this->getLoginAttributeValue($userDn); + if (empty($loginAttribute)) { + throw new LAMException('Unable to read login attribute from ' . $userDn); + } } if ($this->verificationFailed) { return; @@ -867,10 +879,16 @@ class OpenIdProvider extends BaseProvider { $tokenSet = $authorizationService->callback($client, $callbackParams, $_GET['redirect_uri']); $claims = $tokenSet->claims(); logNewMessage(LOG_DEBUG, print_r($claims, true)); - $loginAttribute = $this->getLoginAttributeValue($user); - if ($loginAttribute !== $claims['preferred_username']) { - logNewMessage(LOG_ERR, 'User name ' . $loginAttribute . ' does not match claim preferred_username: ' . $claims['preferred_username']); - return false; + $openIdUser = $claims['preferred_username']; + if (($this->config->loginHandler !== null) && $this->config->loginHandler->managesAuthentication()) { + $this->config->loginHandler->authorize2FaUser($openIdUser); + } + else { + $loginAttribute = $this->getLoginAttributeValue($user); + if ($loginAttribute !== $openIdUser) { + logNewMessage(LOG_ERR, 'User name ' . $loginAttribute . ' does not match claim preferred_username: ' . $openIdUser); + return false; + } } $this->verificationFailed = false; return true; @@ -1250,6 +1268,7 @@ class TwoFactorProviderService { $tfConfig->twoFactorRememberDeviceDuration = $profile->twoFactorRememberDeviceDuration; $tfConfig->twoFactorRememberDevicePassword = $profile->twoFactorRememberDevicePassword; } + $tfConfig->loginHandler = $profile->getLoginHandler(); return $tfConfig; } @@ -1362,4 +1381,9 @@ class TwoFactorConfiguration { */ public string $twoFactorRememberDevicePassword = ''; + /** + * @var SelfServiceLoginHandler|null login handler + */ + public ?SelfServiceLoginHandler $loginHandler = null; + } diff --git a/lam/lib/selfService.inc b/lam/lib/selfService.inc index c6568155f..8a794e992 100644 --- a/lam/lib/selfService.inc +++ b/lam/lib/selfService.inc @@ -925,6 +925,7 @@ class selfServiceProfile { public function getLoginHandler(): SelfServiceLoginHandler { return match ($this->loginHandler) { SelfServiceHttpAuthLoginHandler::ID => new SelfServiceHttpAuthLoginHandler($this), + SelfService2FaLoginHandler::ID => new SelfService2FaLoginHandler($this), default => new SelfServiceUserPasswordLoginHandler($this), }; } @@ -1020,6 +1021,30 @@ interface SelfServiceLoginHandler { */ function getLoginPassword(): string; + /** + * Returns if the login handler manages authentication on its own. + * + * @return bool manages authentication + */ + function managesAuthentication(): bool; + + /** + * Returns if the authentication was successful. + * Only valid if managesAuthentication() returns true. + * + * @return bool authentication successful + * @throws LAMException error during authentication + */ + function isAuthenticationSuccessful(): bool; + + /** + * Authorizes an user that was provided by 2FA provider. + * + * @param string $userName user name + * @throws LAMException error during authentication + */ + function authorize2FaUser(string $userName): void; + } /** @@ -1081,6 +1106,27 @@ class SelfServiceUserPasswordLoginHandler implements SelfServiceLoginHandler { return $_POST['password']; } + /** + * @inheritDoc + */ + function managesAuthentication(): bool { + return false; + } + + /** + * @inheritDoc + */ + function isAuthenticationSuccessful(): bool { + throw new LAMException('Not implemented'); + } + + /** + * @inheritDoc + */ + function authorize2FaUser(string $userName): void { + // no action + } + } /** @@ -1142,4 +1188,137 @@ class SelfServiceHttpAuthLoginHandler implements SelfServiceLoginHandler { return $_SERVER['PHP_AUTH_PW']; } + /** + * @inheritDoc + */ + function managesAuthentication(): bool { + return false; + } + + /** + * @inheritDoc + */ + function isAuthenticationSuccessful(): bool { + throw new LAMException('Not implemented'); + } + + /** + * @inheritDoc + */ + function authorize2FaUser(string $userName): void { + // no action + } + +} + +/** + * Performs login with pure 2FA. + */ +class SelfService2FaLoginHandler implements SelfServiceLoginHandler { + + public const ID = "2fa"; + + private selfServiceProfile $profile; + + /** + * Constructor + * + * @param selfServiceProfile $profile profile + */ + function __construct(selfServiceProfile $profile) { + $this->profile = $profile; + } + + /** + * @inheritDoc + */ + function getId(): string { + return self::ID; + } + + /** + * @inheritDoc + */ + function addLoginFields(htmlResponsiveRow $content): void { + // no input fields + } + + /** + * @inheritDoc + */ + function getLoginName(): string { + return ''; + } + + /** + * @inheritDoc + */ + function getLoginPassword(): string { + return ''; + } + + /** + * @inheritDoc + */ + function managesAuthentication(): bool { + return true; + } + + /** + * @inheritDoc + */ + function isAuthenticationSuccessful(): bool { + if (($this->profile->twoFactorAuthentication !== TwoFactorProviderService::TWO_FACTOR_OKTA) + && ($this->profile->twoFactorAuthentication !== TwoFactorProviderService::TWO_FACTOR_OPENID)) { + logNewMessage(LOG_ERR, 'Unsupported 2FA provider: ' . $this->profile->twoFactorAuthentication); + return false; + } + if (!$this->profile->useForAllOperations) { + logNewMessage(LOG_ERR, 'Use for all operations must be set'); + return false; + } + // authentication will be checked on 2FA page + return true; + } + + /** + * @inheritDoc + */ + function authorize2FaUser(string $userName): void { + $bindUser = $this->profile->LDAPUser; + if (empty($bindUser)) { + throw new LAMException('SelfService2FaLoginHandler', 'No bind user set'); + } + $bindPassword = deobfuscateText($this->profile->LDAPPassword); + $server = connectToLDAP($this->profile->serverURL, $this->profile->useTLS); + if ($server === null) { + throw new LAMException('SelfService2FaLoginHandler', 'Unable to match provided user with LDAP entry - LDAP connect failed'); + } + ldap_set_option($server, LDAP_OPT_REFERRALS, $this->profile->followReferrals); + $bind = @ldap_bind($server, $bindUser, $bindPassword); + if (!$bind) { + throw new LAMException('SelfService2FaLoginHandler', 'Unable to match provided user with LDAP entry - LDAP bind failed'); + } + $filter = '(' . $this->profile->twoFactorAuthenticationAttribute . "=" . ldap_escape($userName) . ')'; + if (!empty($profile->additionalLDAPFilter)) { + $filter = '(&' . $filter . $profile->additionalLDAPFilter . ')'; + } + $result = @ldap_search($server, $this->profile->LDAPSuffix, $filter, ['DN'], 0, 1, 0, LDAP_DEREF_NEVER); + if ($result === false) { + throw new LAMException('SelfService2FaLoginHandler', 'Unable to match provided user with LDAP entry - LDAP search failed'); + } + $entries = @ldap_get_entries($server, $result); + if ($entries === false) { + throw new LAMException('SelfService2FaLoginHandler', 'Unable to match provided user with LDAP entry - LDAP search failed'); + } + $info = $entries; + cleanLDAPResult($info); + if (sizeof($info) === 1) { + $userDN = $info[0]['dn']; + $_SESSION['selfService_clientDN'] = lamEncrypt($userDN, 'SelfService'); + return; + } + throw new LAMException('SelfService2FaLoginHandler', 'Multiple or no results for ' . $userName . ' ' . print_r($info, true)); + } + }