Skip to content
Snippets Groups Projects
AuthenticationController.php 25.3 KiB
Newer Older
Florian Azizian's avatar
Florian Azizian committed
<?php

/**
 * Copyright Maarch since 2008 under license.
 * See LICENSE.txt file at the root folder for more details.
Florian Azizian's avatar
Florian Azizian committed
 * This file is part of Maarch software.
 */

/**
 * @brief Authentication Controller
 *
 * @author dev@maarch.org
 */

namespace SrcCore\controllers;

use Configuration\models\ConfigurationModel;
use Email\controllers\EmailController;
Damien's avatar
Damien committed
use History\controllers\HistoryController;
Damien's avatar
Damien committed
use Respect\Validation\Validator;
use Slim\Psr7\Request;
use SrcCore\http\Response;
Florian Azizian's avatar
Florian Azizian committed
use SrcCore\models\AuthenticationModel;
use SrcCore\models\CoreConfigModel;
use SrcCore\models\PasswordModel;
use SrcCore\models\ValidatorModel;
Damien's avatar
Damien committed
use User\controllers\UserController;
use User\models\UserModel;
Florian Azizian's avatar
Florian Azizian committed

class AuthenticationController
{
    const MAX_DURATION_TOKEN = 30; //Minutes
    const ROUTES_WITHOUT_AUTHENTICATION = [
        'GET/authenticationInformations', 'POST/authenticate', 'GET/authenticate/token',
        'POST/password', 'PUT/password', 'GET/passwordRules', 'GET/languages/{lang}', 'GET/commitInformation',
        'GET/documents/{id}/workflows/{workflowId}/files/{fileId}', 'GET/externalSignatoryBookReturn'
    public function getInformations(Request $request, Response $response)
    {
        $connection = ConfigurationModel::getConnection();
        $encryptKeyChanged = CoreConfigModel::hasEncryptKeyChanged();
        $path = CoreConfigModel::getConfigPath();
        $coreUrl = UrlController::getCoreUrl();
        $hashedPath = md5($path);
        $emailConfiguration = ConfigurationModel::getByIdentifier(['identifier' => 'emailServer', 'select' => ['value']]);
        $emailConfiguration = !empty($emailConfiguration[0]['value']) ? json_decode($emailConfiguration[0]['value'], true) : null;
        $mailServerOnline = !empty($emailConfiguration['online']);
        $customization = ConfigurationModel::getByIdentifier(['identifier' => 'customization', 'select' => ['value']]);
        $customization = !empty($customization[0]['value']) ? json_decode($customization[0]['value'], true) : null;
        if($connection == 'cas') {
            $casConfigurations = ConfigurationModel::getByIdentifier(['identifier' => 'casServer', 'select' => ['value']]);
            if (empty($casConfigurations[0])) {
                return $response->withStatus(400)->withJson(['errors' => 'Cas configuration is empty']);
            }
            $casConfigurations = $casConfigurations[0];
            $casConfigurations = json_decode($casConfigurations['value'] ?? [], true);
            $hostname = (string)$casConfigurations['url'] ?? '';
            $port = (string)$casConfigurations['port'] ?? '';
            $uri = (string)$casConfigurations['context'] ?? '';
            $authUri = "https://{$hostname}:{$port}{$uri}/?service=" . $coreUrl . 'dist/';
        }
            'changeKey'        => !$encryptKeyChanged,
            'instanceId'       => $hashedPath,
            'coreUrl'          => $coreUrl,
            'mailServerOnline' => $mailServerOnline,
            'loginMessage'     => $customization['loginMessage'] ?? null,
            'applicationUrl'   => $customization['applicationUrl'] ?? null,
            'authUri'       => $authUri
    public static function authentication($authorizationHeaders = [])
Florian Azizian's avatar
Florian Azizian committed
    {
        $id = null;
Florian Azizian's avatar
Florian Azizian committed
        if (!empty($_SERVER['PHP_AUTH_USER']) && !empty($_SERVER['PHP_AUTH_PW'])) {
            $login = strtolower($_SERVER['PHP_AUTH_USER']);
            if (AuthenticationModel::authentication(['login' => $login, 'password' => $_SERVER['PHP_AUTH_PW']])) {
                $user = UserModel::getByLogin(['select' => ['id'], 'login' => $login]);
                $id = $user['id'];
Florian Azizian's avatar
Florian Azizian committed
            }
        } else {
            if (!empty($authorizationHeaders)) {
                $token = null;
                foreach ($authorizationHeaders as $authorizationHeader) {
                    if (strpos($authorizationHeader, 'Bearer') === 0) {
                        $token = str_replace('Bearer ', '', $authorizationHeader);
                    }
                }
                if (!empty($token)) {
                    try {
                        $jwt = AuthenticationModel::decodeToken($token, CoreConfigModel::getEncryptKey());
                    } catch (\Exception $e) {
                        return null;
                    }
                    $jwt['user'] = (array)$jwt['user'];
                    if (!empty($jwt) && !empty($jwt['user']['id'])) {
                        $id = $jwt['user']['id'];
                    }
                }
        return $id;
Florian Azizian's avatar
Florian Azizian committed
    }
    public function authenticate(Request $request, Response $response)
Damien's avatar
Damien committed
    {
        $body = $request->getParsedBody();
        $connection = ConfigurationModel::getConnection();
        if (in_array($connection, ['default', 'ldap'])) {
            if (!array_key_exists('login', $body) || !array_key_exists('password', $body) || !Validator::stringType()->notEmpty()->validate($body['login']) || !Validator::stringType()->notEmpty()->validate($body['password'])) {
                return $response->withStatus(400)->withJson(['errors' => 'Bad Request']);
            }
        $login = strtolower($body['login']);
        if ($connection == 'ldap') {
            $ldapConfigurations = ConfigurationModel::getByIdentifier(['identifier' => 'ldapServer', 'select' => ['value']]);
            if (empty($ldapConfigurations)) {
                return $response->withStatus(400)->withJson(['errors' => 'Ldap configuration is missing']);
            }
            foreach ($ldapConfigurations as $ldapConfiguration) {
                $ldapConfiguration = json_decode($ldapConfiguration['value'], true);
                $uri = ($ldapConfiguration['ssl'] === true ? "LDAPS://{$ldapConfiguration['uri']}" : $ldapConfiguration['uri']);
                $ldap = @ldap_connect($uri);
                if ($ldap === false) {
                    $error = 'Ldap connect failed : uri is maybe wrong';
                    continue;
                }
                ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
                ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
                ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 10);
                $ldapLogin = (!empty($ldapConfiguration['prefix']) ? $ldapConfiguration['prefix'] . '\\' . $body['login'] : $body['login']);
                $ldapLogin = (!empty($ldapConfiguration['suffix']) ? $ldapLogin . $ldapConfiguration['suffix'] : $ldapLogin);
                if (!empty($ldapConfiguration['baseDN'])) { //OpenLDAP
                    $search = @ldap_search($ldap, $ldapConfiguration['baseDN'], "(uid={$ldapLogin})", ['dn']);
                    if ($search === false) {
                        $error = 'Ldap search failed : baseDN is maybe wrong => ' . ldap_error($ldap);
                    $entries = @ldap_get_entries($ldap, $search);
                    if ($entries === false) {
                        $error = 'Ldap fetching failed : ' . ldap_error($ldap);
                        continue;
                    }
                    if ($entries['count'] < 1) {
                        $error = 'No entries found in ldap search : invalid user DN or ldap configuration';
                        continue;
                    }
                $authenticated = @ldap_bind($ldap, $ldapLogin, $body['password']);
                if (!$authenticated) {
                    $error = ldap_error($ldap);
            if (empty($authenticated) && !empty($error) && $error != 'Invalid credentials') {
                return $response->withStatus(400)->withJson(['errors' => $error]);
        } elseif ($connection == 'x509') {
            if (!empty($_SERVER['SSL_CLIENT_CERT'])) {
                $x509Fingerprint = openssl_x509_fingerprint($_SERVER["SSL_CLIENT_CERT"]);
                $user = UserModel::get(['select' => ['login'], 'where' => ['x509_fingerprint = ?'], 'data' => [strtoupper(wordwrap($x509Fingerprint, 2, " ", true))]]);
                if (empty($user)) {
                    return $response->withStatus(401)->withJson(['errors' => 'Authentication unauthorized']);
                }
                $login = $user[0]['login'];
            } else {
                return $response->withStatus(401)->withJson(['errors' => 'No certificate detected']);
            }
            $authenticated = true;
        } elseif ($connection == 'kerberos') {
            if (!empty($_SERVER['REMOTE_USER']) && $_SERVER['AUTH_TYPE'] == 'Negotiate') {
                $login = strtolower($_SERVER['REMOTE_USER']);
            } else {
                return $response->withStatus(401)->withJson(['errors' => 'No identifier detected for kerberos']);
            }
            $authenticated = true;
        } else if ($connection == 'azure_saml') {
            $authenticated = AuthenticationController::azureSamlConnection();
            if (!empty($authenticated['errors'])) {
                return $response->withStatus(401)->withJson(['errors' => $authenticated['errors']]);
            }
            $login = strtolower($authenticated['login']);
            $authenticated = true;
        }  elseif ($connection == 'cas') {
            $authenticated = AuthenticationController::casConnection();
            $login = $authenticated['login'];
            if (!empty($authenticated['errors'])) {
                return $response->withStatus(401)->withJson(['errors' => $authenticated['errors']]);
            }
        } else {
            $authenticated = AuthenticationModel::authentication(['login' => $login, 'password' => $body['password']]);
        if (empty($authenticated)) {
            $user = UserModel::getByLogin(['login' => $login, 'select' => ['id']]);

            if (!empty($user)) {
                $handle = AuthenticationController::handleFailedAuthentication(['userId' => $user['id']]);
                if (!empty($handle['accountLocked'])) {
                    return $response->withStatus(401)->withJson(['errors' => 'Account Locked', 'date' => $handle['lockedDate']]);
                }
            }
Damien's avatar
Damien committed
            return $response->withStatus(401)->withJson(['errors' => 'Authentication Failed']);
        }

        $user = UserModel::getByLogin(['login' => $login, 'select' => ['id', '"isRest"', 'refresh_token']]);
        if (empty($user) || $user['isRest']) {
            return $response->withStatus(403)->withJson(['errors' => 'Authentication unauthorized']);
        $GLOBALS['id'] = $user['id'];
        $user['refresh_token'] = json_decode($user['refresh_token'], true);
        foreach ($user['refresh_token'] as $key => $refreshToken) {
                AuthenticationModel::decodeToken($refreshToken, CoreConfigModel::getEncryptKey());
            } catch (\Exception $e) {
                unset($user['refresh_token'][$key]);
            }
        }
        $user['refresh_token'] = array_values($user['refresh_token']);
        if (count($user['refresh_token']) > 10) {
            array_shift($user['refresh_token']);
        }

        $refreshToken = AuthenticationController::getRefreshJWT();
        $user['refresh_token'][] = $refreshToken;
        UserModel::update([
            'set'   => ['reset_token' => null, 'refresh_token' => json_encode($user['refresh_token'])],
            'where' => ['id = ?'],
            'data'  => [$user['id']]
        ]);
        $response = $response->withHeader('Token', AuthenticationController::getJWT());
        $response = $response->withHeader('Refresh-Token', $refreshToken);
Damien's avatar
Damien committed

Damien's avatar
Damien committed
        HistoryController::add([
Damien's avatar
Damien committed
            'code'          => 'OK',
            'objectType'    => 'users',
            'objectId'      => $user['id'],
            'type'          => 'LOGIN',
            'message'       => '{userLogIn}'
        return $response->withStatus(204);
Damien's avatar
Damien committed
    }
    private static function casConnection()
    {
        $casConfigurations = ConfigurationModel::getByIdentifier(['identifier' => 'casServer', 'select' => ['value']]);

        if (empty($casConfigurations[0])) {
            return ['errors' => 'Cas authentication failed'];
        }

        $casConfigurations = $casConfigurations[0];
        $casConfigurations = json_decode($casConfigurations['value'], true);

        $version = (string)$casConfigurations['version'];
        $hostname = (string)$casConfigurations['url'];
        $port = (string)$casConfigurations['port'];
        $uri = (string)$casConfigurations['context'];
        $certificate = (string)$casConfigurations['certificate'];
        $separator = (string)$casConfigurations['separator'];

        if (!in_array($version, ['CAS_VERSION_2_0', 'CAS_VERSION_3_0'])) {
            return ['errors' => 'Cas version not supported'];
        }

        \phpCAS::client(constant($version), $hostname, (int)$port, $uri, $version != 'CAS_VERSION_3_0');
        if (!empty($certificate)) {
            \phpCAS::setCasServerCACert($certificate);
        } else {
            \phpCAS::setNoCasServerValidation();
        }
        \phpCAS::setFixedServiceURL(UrlController::getCoreUrl() . 'dist/');
        \phpCAS::setNoClearTicketsFromUrl();
        if (!\phpCAS::isAuthenticated()) {
            return ['errors' => 'Cas authentication failed'];
        }

        $casId = \phpCAS::getUser();

        if (!empty($separator)) {
            $login = explode($separator, $casId)[0];
        } else {
            $login = $casId;
        }

        return ['login' => $login];
    }

    private static function casDisconnection()
    {
        $casConfigurations = ConfigurationModel::getByIdentifier(['identifier' => 'casServer', 'select' => ['value']]);
        if (empty($casConfigurations)) {
            return ['errors' => 'Cas authentication failed'];
        }
        $casConfigurations = $casConfigurations[0];
        $casConfigurations = json_decode($casConfigurations['value'], true);

        $version = (string)$casConfigurations['version'];
        $hostname = (string)$casConfigurations['url'];
        $port = (string)$casConfigurations['port'];
        $uri = (string)$casConfigurations['context'];
        $certificate = (string)$casConfigurations['certificate'];

        \phpCAS::setVerbose(true);
        \phpCAS::client(constant($version), $hostname, (int)$port, $uri, $version != 'CAS_VERSION_3_0');

        if (!empty($certificate)) {
            \phpCAS::setCasServerCACert($certificate);
        } else {
            \phpCAS::setNoCasServerValidation();
        }
        \phpCAS::setFixedServiceURL(UrlController::getCoreUrl() . 'dist/');
        \phpCAS::setNoClearTicketsFromUrl();
        $logoutUrl = \phpCAS::getServerLogoutURL();

        return ['logoutUrl' => $logoutUrl];
    }

    private static function azureSamlConnection()
    {
        $libPath = CoreConfigModel::getSimpleSamlLibrary();
        if (!is_file($libPath)) {
            return ['errors' => 'Library simplesamlphp not present'];
        }

        $as = new \SimpleSAML\Auth\Simple('default-sp');
        $as->requireAuth([
            'ReturnTo'          => UrlController::getCoreUrl(),
            'skipRedirection'   => true
        ]);

        $attributes = $as->getAttributes();
        $login = $attributes['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'][0];
        if (empty($login)) {
            return ['errors' => 'Authentication Failed : login not present in attributes'];
        }

        return ['login' => $login];
    }

    public function getRefreshedToken(Request $request, Response $response)
        $queryParams = $request->getQueryParams();
        if (!Validator::stringType()->notEmpty()->validate($queryParams['refreshToken'])) {
            return $response->withStatus(400)->withJson(['errors' => 'Refresh Token is empty']);
        }

        try {
            $jwt = AuthenticationModel::decodeToken($queryParams['refreshToken'], CoreConfigModel::getEncryptKey());
            $jwt['user'] = (array)$jwt['user'] ?? [];
        } catch (\Exception $e) {
            return $response->withStatus(401)->withJson(['errors' => 'Authentication Failed']);
        }

        $user = UserModel::getById(['select' => ['id', 'refresh_token'], 'id' => $jwt['user']['id']]);
        if (empty($user['refresh_token'])) {
            return $response->withStatus(401)->withJson(['errors' => 'Authentication Failed']);
        }

        $user['refresh_token'] = json_decode($user['refresh_token'], true);
        if (!in_array($queryParams['refreshToken'], $user['refresh_token'])) {
            return $response->withStatus(401)->withJson(['errors' => 'Authentication Failed']);
        }

        $GLOBALS['id'] = $user['id'];

        return $response->withJson(['token' => AuthenticationController::getJWT()]);
    public function logout(Request $request, Response $response)
    {
        $loggingMethod = ConfigurationModel::getConnection();
        $logoutUrl = null;
        if ($loggingMethod == 'cas') {
            $disconnection = AuthenticationController::casDisconnection();
            $logoutUrl = $disconnection['logoutUrl'];
        }

        HistoryController::add([
            'code'          => 'OK',
            'objectType'    => 'users',
            'objectId'      => $GLOBALS['id'],
            'type'          => 'LOGOUT',
            'message'       => '{userLogOut}'
        ]);

        return $response->withJson(['logoutUrl' => $logoutUrl]);
    public static function getJWT()
    {
        $sessionTime = AuthenticationController::MAX_DURATION_TOKEN;

        $loadedJson = CoreConfigModel::getConfig();
        if (!empty($loadedJson['config']['sessionTime'])) {
            if ($sessionTime > $loadedJson['config']['sessionTime']) {
                $sessionTime = $loadedJson['config']['sessionTime'];
            }
        }

        $user = UserController::getUserInformationsById(['id' => $GLOBALS['id']]);
        unset($user['picture']);

        $token = [
            'exp'        => time() + 60 * $sessionTime,
            'user'       => $user,
            'connection' => ConfigurationModel::getConnection()
        return AuthenticationModel::generateToken($token, CoreConfigModel::getEncryptKey());
    public static function getRefreshJWT()
        $sessionTime = AuthenticationController::MAX_DURATION_TOKEN;
        $loadedJson = CoreConfigModel::getConfig();

        if (!empty($loadedJson['config']['sessionTime'])) {
            $sessionTime = $loadedJson['config']['sessionTime'];
            'exp'   => time() + 60 * $sessionTime,
            'user'  => [
                'id' => $GLOBALS['id']
            ]
        ];

        return AuthenticationModel::generateToken($token, CoreConfigModel::getEncryptKey());
    public static function getResetJWT($args = [])
            'exp'   => time() + $args['expirationTime'],
            ],
            'connection' => ConfigurationModel::getConnection()
        return AuthenticationModel::generateToken($token, CoreConfigModel::getEncryptKey());
    public static function sendAccountActivationNotification(array $args)
    {
        $connection = ConfigurationModel::getConnection();
        if ($connection == 'default') {
            $resetToken = AuthenticationController::getResetJWT(['id' => $args['userId'], 'expirationTime' => 1209600]); // 14 days
            UserModel::update(['set' => ['reset_token' => $resetToken], 'where' => ['id = ?'], 'data' => [$args['userId']]]);
    
            $user = UserModel::getById(['select' => ['login'], 'id' => $args['userId']]);
            $lang = LanguageController::get(['lang' => 'fr']);
    
            $url = ConfigurationModel::getApplicationUrl() . 'dist/#/update-password?token=' . $resetToken;
            EmailController::createEmail([
                'userId' => $args['userId'],
                'data'   => [
                    'sender'     => 'Notification',
                    'recipients' => [$args['userEmail']],
                    'subject'    => $lang['notificationNewAccountSubject'],
                    'body'       => $lang['notificationNewAccountBody'] . '<a href="' . $url . '">'.$url.'</a>' . $lang['notificationNewAccountId'] . ' ' . $user['login'] . $lang['notificationNewAccountFooter'],
                    'isHtml'     => true
                ]
            ]);
        }
    public static function isRouteAvailable(array $args)
    {
        ValidatorModel::notEmpty($args, ['userId', 'currentRoute']);
        ValidatorModel::intVal($args, ['userId']);
        ValidatorModel::stringType($args, ['currentRoute']);

        $user = UserModel::getById(['select' => ['password_modification_date', '"isRest"'], 'id' => $args['userId']]);

        if (!in_array($args['currentRoute'], ['/passwordRules', '/users/{id}/password']) && empty($user['isRest'])) {
            $connectionConfiguration = ConfigurationModel::getByIdentifier(['identifier' => 'connection', 'select' => ['value']]);

            if ($connectionConfiguration[0]['value'] == '"default"') {
                $passwordRules = PasswordModel::getEnabledRules();
                if (!empty($passwordRules['renewal'])) {
                    $currentDate = new \DateTime();
                    $lastModificationDate = new \DateTime($user['password_modification_date']);
                    $lastModificationDate->add(new \DateInterval("P{$passwordRules['renewal']}D"));

                    if ($currentDate > $lastModificationDate) {
                        return ['isRouteAvailable' => false, 'errors' => 'Password expired : User must change his password'];
                    }
                }
            }
        }

        return ['isRouteAvailable' => true];
    }

    public static function handleFailedAuthentication(array $args)
    {
        ValidatorModel::notEmpty($args, ['userId']);
        ValidatorModel::intVal($args, ['userId']);

        $passwordRules = PasswordModel::getEnabledRules();

        if (!empty($passwordRules['lockAttempts'])) {
            $user = UserModel::getById(['select' => ['failed_authentication', 'locked_until'], 'id' => $args['userId']]);
            $set = [];
            if (!empty($user['locked_until'])) {
                $currentDate = new \DateTime();
                $lockedUntil = new \DateTime($user['locked_until']);
                if ($lockedUntil < $currentDate) {
                    $set['locked_until'] = null;
                    $user['failed_authentication'] = 0;
                } else {
                    return ['accountLocked' => true, 'lockedDate' => $user['locked_until']];
                }
            }

            $set['failed_authentication'] = $user['failed_authentication'] + 1;
            UserModel::update([
                'set'       => $set,
                'where'     => ['id = ?'],
                'data'      => [$args['userId']]
            ]);

            if (!empty($user['failed_authentication']) && ($user['failed_authentication'] + 1) >= $passwordRules['lockAttempts'] && !empty($passwordRules['lockTime'])) {
                $lockedUntil = time() + 60 * $passwordRules['lockTime'];
                UserModel::update([
                    'set'       => ['locked_until'  => date('Y-m-d H:i:s', $lockedUntil)],
                    'where'     => ['id = ?'],
                    'data'      => [$args['userId']]
                ]);
                return ['accountLocked' => true, 'lockedDate' => date('Y-m-d H:i:s', $lockedUntil)];
            }
        }

        return true;
    }
    public function getGitRepoInformation(Request $request, Response $response)
    {
        $url = null;

        if (file_exists('package.json')) {
            $license = json_decode(file_get_contents('package.json'), true);

            if (!empty($license['license'])) {
                switch (strtolower($license['license'])) {
                    case 'gpl-3.0':
                        $url = 'https://labs.maarch.org/maarch/MaarchParapheur';
                        break;
                    case 'maarch':
                        $url = 'https://labs.maarch.org/maarch/MaarchParapheurPro';
                        break;
                    default:
                        break;
                }
            }
    public function getGitCommitInformation(Request $request, Response $response)
    {
        if (!file_exists('.git/HEAD')) {
            return $response->withJson(['hash' => null]);
        }

        $head = file_get_contents('.git/HEAD');

        if ($head === false) {
            return $response->withJson(['hash' => null]);
        }

        if (preg_match('#^ref:(.+)$#', $head, $matches)) {
            // The HEAD file contains a reference to a branch
            $currentHead = trim($matches[1]);
        } else {
            // The HEAD file contains a hash
            return $response->withJson(['hash' => trim($head)]);
        }

        if (empty($currentHead)) {
            return $response->withJson(['hash' => null]);
        }

        $hash = file_get_contents('.git/' . $currentHead);
        if ($hash === false) {
            return $response->withJson(['hash' => null]);
        }

        $hash = explode("\n", $hash)[0];

        return $response->withJson(['hash' => $hash]);
    }
Florian Azizian's avatar
Florian Azizian committed
}