|
+ |
+
+
+ + +
+
|
+
diff --git a/.gitignore b/.gitignore index a238cbe..38af445 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ assets/js/app.js assets/css/plugin-MultiplLocalAuth.css mix-manifest.json node_modules/ +vendor/ +.phpunit.cache/ +composer.phar +composer-setup.php diff --git a/AccountLifecycleService.php b/AccountLifecycleService.php new file mode 100644 index 0000000..4b0684e --- /dev/null +++ b/AccountLifecycleService.php @@ -0,0 +1,126 @@ + false, + 'accountInTrash' => true, + 'profileName' => $profileName, + 'errors' => $errors, + ]; + } + + /** + * Pré-condições de confirmRestoreAccount(). + * Retorna 'expired' | 'not_trash' | null (ok). + * + * @param object|null $user objeto com propriedade status (User ou stub) + */ + public static function confirmRestoreError(?int $sessionUserId, $user, int $trashStatus = self::STATUS_TRASH): ?string + { + if (!$sessionUserId) { + return 'expired'; + } + + if (!$user || (int) $user->status !== $trashStatus) { + return 'not_trash'; + } + + return null; + } + + public static function shouldRestoreOnEmailConfirm(?string $pendingTrashRestoreConfirmMetadata): bool + { + return $pendingTrashRestoreConfirmMetadata === '1'; + } + + public static function relatedEntityTypesToRestore(): array + { + return self::RELATED_ENTITY_TYPES; + } + + public static function entityShouldBeUndeleted(int $entityStatus, int $trashStatus = self::STATUS_TRASH): bool + { + return $entityStatus === $trashStatus; + } + + public static function storePendingTrashRestore(int $userId): void + { + $_SESSION[self::PENDING_TRASH_RESTORE_SESSION_KEY] = $userId; + } + + public static function getPendingTrashRestoreUserId(): ?int + { + $userId = $_SESSION[self::PENDING_TRASH_RESTORE_SESSION_KEY] ?? null; + if ($userId === null || $userId === '') { + return null; + } + + return (int) $userId; + } + + public static function clearPendingTrashRestore(): void + { + unset($_SESSION[self::PENDING_TRASH_RESTORE_SESSION_KEY]); + } +} diff --git a/Decidim/DecidimStrategy.php b/Decidim/DecidimStrategy.php new file mode 100644 index 0000000..f298641 --- /dev/null +++ b/Decidim/DecidimStrategy.php @@ -0,0 +1,245 @@ + 'email'); + */ + public $defaults = ['redirect_uri' => '{complete_url_to_strategy}oauth2callback']; + + /** + * Auth request + */ + public function request(){ + $url = $this->strategy['auth_endpoint']; + $params = array( + 'client_id' => $this->strategy['client_id'], + 'client_secret' => $this->strategy['client_secret'], + 'redirect_uri' => $this->strategy['redirect_uri'], + 'response_type' => 'code', + 'scope' => $this->strategy['scope'] + ); + foreach ($this->optionals as $key){ + if (!empty($this->strategy[$key])) $params[$key] = $this->strategy[$key]; + } + + $this->clientGet($url, $params); + } + + /** + * Internal callback, after OAuth + */ + public function oauth2callback(){ + if (array_key_exists('code', $_GET) && !empty($_GET['code'])){ + $code = $_GET['code']; + $url = $this->strategy['token_endpoint']; + $params = array( + 'code' => $code, + 'client_id' => $this->strategy['client_id'], + 'client_secret' => $this->strategy['client_secret'], + 'redirect_uri' => $this->strategy['redirect_uri'], + 'grant_type' => 'authorization_code' + ); + $response = $this->serverPost($url, $params, null, $headers); + + $results = json_decode($response); + + if (!empty($results) && !empty($results->access_token)){ + + $userinfo = $this->userinfo($results->access_token); + + + $this->auth = array( + 'uid' => $userinfo['id'], + 'info' => array(), + 'credentials' => array( + 'token' => $results->access_token, + 'expires' => date('c', time() + $results->expires_in) + ), + 'raw' => $userinfo + ); + + + if (!empty($results->refresh_token)) + { + $this->auth['credentials']['refresh_token'] = $results->refresh_token; + } + + $this->mapProfile($userinfo, 'name', 'info.name'); + $this->mapProfile($userinfo, 'email', 'info.email'); + $this->mapProfile($userinfo, 'given_name', 'info.first_name'); + $this->mapProfile($userinfo, 'family_name', 'info.last_name'); + $this->mapProfile($userinfo, 'picture', 'info.image'); + + $this->callback(); + } + else{ + $error = array( + 'code' => 'access_token_error', + 'message' => 'Failed when attempting to obtain access token', + 'raw' => array( + 'response' => $response, + 'headers' => $headers + ) + ); + $this->errorCallback($error); + } + } + else{ + $error = array( + 'code' => 'oauth2callback_error', + 'raw' => $_GET + ); + + $this->errorCallback($error); + } + } + + /** + * Queries Google API for user info + * + * @param string $access_token + * @return array Parsed JSON results + */ + private function userinfo($access_token){ + $options = [ + 'http' => [ + 'header' => "Authorization: Bearer {$access_token}\r\nAccept: application/json", + 'ignore_errors' => true, + 'method' => 'GET' + ] + ]; + + // Alterado para passar os headers corretamente e manter o uso do serverGet + $userinfo = $this->serverGet($this->strategy['userinfo_endpoint'], [], $options, $responseHeaders); + // $userinfo = $this->serverGet($this->strategy['userinfo_endpoint'], array('access_token' => $access_token), null, $headers); + + if (!empty($userinfo)){ + return $this->recursiveGetObjectVars(json_decode($userinfo)); + } + else{ + $error = array( + 'code' => 'userinfo_error', + 'message' => 'Failed when attempting to query for user information', + 'raw' => array( + 'response' => $userinfo, + 'headers' => $headers + ) + ); + $this->errorCallback($error); + } + } + + /** + * Atualiza dados do usuário autenticado a partir da resposta da estratégia Decidim. + * + * @param \MapasCulturais\Entities\User $user Usuário autenticado que terá os dados atualizados. + * @param array $response Resposta completa retornada pela estratégia Decidim. + * @return void + */ + public static function verifyUpdateData($user, $response) + { + $app = App::i(); + + $userinfo = (object) $response['auth']['raw']; + + self::getFile($user->profile, $userinfo->image); + } + + /** + * Faz o download de uma imagem remota e salva como avatar para o agente informado. + * + * @param \MapasCulturais\Entities\Agent $owner Agente proprietário do avatar. + * @param string|null $url URL da imagem a ser baixada. + * @return void + */ + public static function getFile($owner, $url){ + + $curl = new Curl; + $curl->get($url); + $curl->close(); + $response = $curl->response; + + if(mb_strpos($response, 'não encontrada')){ + return; + } + + $tmp = tempnam("/tmp", ""); + $handle = fopen($tmp, "wb"); + fwrite($handle,$response); + fclose($handle); + + // Confere MIME e extensões aceitas + if (!self::checkFileType($tmp)) { + unlink($tmp); + return; + } + + $mime = mime_content_type($tmp) ?: 'application/octet-stream'; + + $extension = match ($mime) { + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => null, + }; + + if(!$extension) { + unlink($tmp); + return; + } + + $basename = sprintf('%s.%s', md5(uniqid('', true)), $extension); + + $class_name = $owner->fileClassName; + + $file = new $class_name([ + "name" => $basename, + "type" => $mime, + "tmp_name" => $tmp, + "error" => 0, + "size" => filesize($tmp) + ]); + + $file->group = "avatar"; + $file->owner = $owner; + $file->save(true); + + if(is_file($tmp)) { + unlink($tmp); + } + } + + /** + * Verifica se um arquivo temporário corresponde a um formato de imagem suportado. + * + * @param string $filename Caminho absoluto do arquivo temporário a ser verificado. + * @return bool Retorna true se o arquivo for uma imagem suportada; caso contrário, false. + */ + public static function checkFileType($filename) + { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimetype = finfo_file($finfo, $filename); + if ($mimetype == 'image/jpg' || $mimetype == 'image/jpeg' || $mimetype == 'image/gif' || $mimetype == 'image/png') { + $is_image = true; + } else { + $is_image = false; + } + + return $is_image; + } + +} diff --git a/GovBr/GovBrAccountService.php b/GovBr/GovBrAccountService.php new file mode 100644 index 0000000..0484a63 --- /dev/null +++ b/GovBr/GovBrAccountService.php @@ -0,0 +1,182 @@ +em->createQuery( + 'SELECT u.id FROM MapasCulturais\Entities\User u WHERE LOWER(u.email) = :email' + ); + $query->setParameter('email', $email); + $query->setMaxResults(1); + + return (bool) $query->getOneOrNullResult(); + } + + /** + * Conta Gov.br nova com e-mail do GOV já usado por outro usr. + * + * @param callable|null $emailExistsChecker fn(string $email): bool + */ + public static function hasEmailConflictOnCreate(array $response, ?callable $emailExistsChecker = null): bool + { + if (!self::isGovBrProvider($response['auth']['provider'] ?? null)) { + return false; + } + + $email = self::extractEmailFromResponse($response); + if ($email === null) { + // Sem e-mail verificado no GOV: também exige coleta na UI. + return true; + } + + return self::emailExists($email, $emailExistsChecker); + } + + public static function applyEmailToResponse(array $response, string $email): array + { + $email = self::normalizeEmail($email); + if ($email === null) { + throw new \InvalidArgumentException('E-mail inválido.'); + } + + $response['auth']['info']['email'] = $email; + return $response; + } + + public static function storePendingRegistration(array $response): void + { + $_SESSION[self::SESSION_PENDING_KEY] = $response; + } + + public static function getPendingRegistration(): ?array + { + $pending = $_SESSION[self::SESSION_PENDING_KEY] ?? null; + return is_array($pending) ? $pending : null; + } + + public static function clearPendingRegistration(): void + { + unset($_SESSION[self::SESSION_PENDING_KEY]); + } + + /** + * Valida e-mail alternativo informado na UI. + * Retorna lista de erros (vazia = ok). + * + * @param callable|null $emailExistsChecker fn(string $email): bool + * @return string[] + */ + public static function validateAlternateEmail(?string $email, ?callable $emailExistsChecker = null): array + { + $errors = []; + $email = self::normalizeEmail($email); + + if ($email === null || !self::isValidEmail($email)) { + $errors[] = 'Informe um e-mail válido.'; + return $errors; + } + + if (self::emailExists($email, $emailExistsChecker)) { + $errors[] = 'Este e-mail já está em uso. Informe outro e-mail.'; + } + + return $errors; + } + + public static function profileCpfMatchesGovBr(User $user, array $response, string $metadataFieldCpf): bool + { + $govCpf = self::extractCpfFromResponse($response); + if ($govCpf === null || !$user->profile) { + return false; + } + + $profileCpf = self::maskCpf((string) ($user->profile->$metadataFieldCpf ?? '')); + if ($profileCpf === null) { + $profileCpf = self::maskCpf((string) ($user->profile->cpf ?? '')); + } + + return $profileCpf !== null && $profileCpf === $govCpf; + } +} diff --git a/GovBr/GovBrStrategy.php b/GovBr/GovBrStrategy.php index 65d80d3..8c622cd 100644 --- a/GovBr/GovBrStrategy.php +++ b/GovBr/GovBrStrategy.php @@ -94,8 +94,10 @@ public function oauth2callback() 'dic_agent_fields_update' => $this->strategy['dic_agent_fields_update'] ]; + // CPF (sub) é a identidade estável no Gov.br. + // jti muda a cada token e não pode ser usado como authUid. $this->auth = array( - 'uid' => $userinfo->jti, + 'uid' => $userinfo->sub, 'credentials' => array( 'token' => $results->id_token, 'expires' => $userinfo->exp @@ -233,12 +235,18 @@ public static function applySeal($user, $response){ $app = App::i(); $agent = $user->profile; - $sealId = $response['auth']['applySeal']; + $sealId = $response['auth']['applySeal'] ?? null; if($sealId){ $app->disableAccessControl(); $seal = $app->repo('Seal')->find($sealId); + if (!$seal) { + $app->log->error("Gov.br applySeal: selo {$sealId} não encontrado"); + $app->enableAccessControl(); + return; + } + $relations = $agent->getSealRelations(); $has_new_seal = false; @@ -264,6 +272,17 @@ public static function verifyUpdateData($user, $response) $auth_data = $response['auth']['info']; $userinfo = (object) $response['auth']['raw']; + $metadataFieldCpf = $app->config['auth.config']['metadataFieldCPF'] ?? 'documento'; + + // Nunca sobrescrever perfil de outra pessoa (CPF diferente do token Gov.br). + $govCpf = \MultipleLocalAuth\GovBrAccountService::extractCpfFromResponse($response); + $profileCpf = \MultipleLocalAuth\GovBrAccountService::maskCpf((string) ($user->profile->$metadataFieldCpf ?? '')); + if ($profileCpf === null) { + $profileCpf = \MultipleLocalAuth\GovBrAccountService::maskCpf((string) ($user->profile->cpf ?? '')); + } + if ($govCpf && $profileCpf && $govCpf !== $profileCpf) { + return; + } $app->hook("entity(Agent).get(lockedFields)", function(&$lockedFields) use ($app){ $config = $app->config['auth.config']['strategies']['govbr']; diff --git a/Plugin.php b/Plugin.php index 9ad2020..1cb1a54 100644 --- a/Plugin.php +++ b/Plugin.php @@ -9,6 +9,10 @@ include('LinkedIn/LinkedInStrategy.php'); include('LoginCidadao/LoginCidadaoStrategy.php'); include('GovBr/GovBrStrategy.php'); +if (!class_exists(__NAMESPACE__ . '\\GovBrAccountService', false)) { + include __DIR__ . '/GovBr/GovBrAccountService.php'; +} +include('Decidim/DecidimStrategy.php'); class Plugin extends \MapasCulturais\Plugin { @@ -56,6 +60,8 @@ public function register() { $this->registerUserMetadata(Provider::$accountIsActiveMetadata, ['label' => i::__('Conta ativa?')]); $this->registerUserMetadata(Provider::$tokenVerifyAccountMetadata, ['label' => i::__('Token de verificação')]); $this->registerUserMetadata(Provider::$loginAttempMetadata, ['label' => i::__('Número de tentativas de login')]); - $this->registerUserMetadata(Provider::$timeBlockedloginAttempMetadata, ['label' => i::__('Tempo de bloqueio por excesso de tentativas')]); + $this->registerUserMetadata(Provider::$timeBlockedloginAttempMetadata, ['label' => i::__('Tempo de bloqueio por excesso de tentativas')]); + $this->registerUserMetadata(Provider::$pendingTrashRestoreConfirmMetadata, ['label' => i::__('Aguardando confirmação de recuperação de conta')]); + $this->registerUserMetadata(Provider::$forcePasswordChangeMetadata, ['label' => i::__('Forçar troca de senha no próximo login')]); } } diff --git a/Provider.php b/Provider.php index a252964..57130a4 100644 --- a/Provider.php +++ b/Provider.php @@ -1,11 +1,17 @@ env('AUTH_LOGIN_ON_REGISTER', false), 'enableLoginByCPF' => env('AUTH_LOGIN_BY_CPF', true), + 'requireCpf' => env('AUTH_REQUIRED_CPF', true), + 'passwordMustHaveCapitalLetters' => env('AUTH_PASS_CAPITAL_LETTERS', true), 'passwordMustHaveLowercaseLetters' => env('AUTH_PASS_LOWERCASE_LETTERS', true), 'passwordMustHaveSpecialCharacters' => env('AUTH_PASS_SPECIAL_CHARS', true), @@ -63,7 +76,7 @@ function __construct ($config) { 'urlImageToUseInEmails' => env('AUTH_EMAIL_IMAGE'), 'urlTermsOfUse' => env('LINK_TERMOS', $app->createUrl('auth', 'termos-e-condicoes')), - 'statusCreateAgent' => env('STATUS_CREATE_AGENT', Agent::STATUS_DRAFT), + 'statusCreateAgent' => env('STATUS_CREATE_AGENT', Agent::STATUS_ENABLED), 'strategies' => [ 'Facebook' => [ 'visible' => env('AUTH_FACEBOOK_CLIENT_ID', false), @@ -109,6 +122,17 @@ function __construct ($config) { 'applySealId' => env('AUTH_GOV_BR_APPLY_SEAL_ID', null), 'menssagem_authenticated' => env('AUTH_GOV_BR_MENSSAGEM_AUTHENTICATED','Usuário já se autenticou pelo GovBr'), 'dic_agent_fields_update' => env('AUTH_GOV_BR_DICT_AGENT_FIELDS_UPDATE','[]') + ], + 'decidim' => [ + 'visible' => env('AUTH_DECIDIM_CLIENT_ID', false), + 'client_id' => env('AUTH_DECIDIM_CLIENT_ID', null), + 'client_secret' => env('AUTH_DECIDIM_CLIENT_SECRET', null), + 'redirect_uri' => env('AUTH_DECIDIM_REDIRECT_URI', null), + 'scope' => env('AUTH_DECIDIM_SCOPE', null), + 'auth_endpoint' => env('AUTH_DECIDIM_AUTH_ENDPOINT', null), + 'token_endpoint' => env('AUTH_DECIDIM_TOKEN_ENDPOINT', null), + 'userinfo_endpoint' => env('AUTH_DECIDIM_USERINFO_ENDPOINT', null), + 'button_text' => env('AUTH_DECIDIM_BUTTON_TEXT', 'Entrar com Decidim'), ] ] ]; @@ -166,6 +190,14 @@ protected function _init() { $user->setMetadata(Provider::$accountIsActiveMetadata, '1'); $app->disableAccessControl(); + + // só agora, com o link do email confirmado, a conta (e tudo que foi pra lixeira + // junto com ela) realmente sai da lixeira. + if (AccountLifecycleService::shouldRestoreOnEmailConfirm($user->getMetadata(Provider::$pendingTrashRestoreConfirmMetadata))) { + $user->setMetadata(Provider::$pendingTrashRestoreConfirmMetadata, '0'); + $app->auth->restoreUserFromTrash($user, true); + } + $user->saveMetadata(true); $app->enableAccessControl(); $app->em->flush(); @@ -257,8 +289,11 @@ protected function _init() { } // add actions to auth controller - $app->hook('GET(auth.index)', function () use($config){ - $this->render('multiple-local', [ 'config' => $config ]); + $app->hook('GET(auth.index)', function () use($app, $config){ + $this->render('multiple-local', [ + 'config' => $config, + 'forcePasswordChange' => $app->auth->userMustChangePassword(), + ]); }); $app->hook('GET(auth.register)', function () use($config){ @@ -295,6 +330,75 @@ protected function _init() { } }); + // Coleta de e-mail alternativo quando o e-mail do Gov.br já existe no Mapa + $app->hook('GET(auth.govbr-email)', function () use ($app, $config) { + $pending = GovBrAccountService::getPendingRegistration(); + if (!$pending) { + $app->redirect($this->createUrl('')); + return; + } + + $conflict_email = GovBrAccountService::extractEmailFromResponse($pending); + $this->render('govbr-email', [ + 'config' => $config, + 'conflictEmail' => $conflict_email, + 'formAction' => $app->createUrl('auth', 'govbr-email'), + ]); + }); + + $app->hook('POST(auth.govbr-email)', function () use ($app, $config) { + /** @var \MultipleLocalAuth\Provider $auth */ + $auth = $app->auth; + $pending = GovBrAccountService::getPendingRegistration(); + if (!$pending) { + $app->redirect($this->createUrl('')); + return; + } + + $new_email = $app->request->post('email'); + $errors = GovBrAccountService::validateAlternateEmail($new_email); + if ($errors) { + $this->render('govbr-email', [ + 'config' => $config, + 'conflictEmail' => GovBrAccountService::extractEmailFromResponse($pending), + 'formAction' => $app->createUrl('auth', 'govbr-email'), + 'errors' => $errors, + 'triedEmail' => $new_email, + ]); + return; + } + + $response = GovBrAccountService::applyEmailToResponse($pending, $new_email); + GovBrAccountService::clearPendingRegistration(); + + $user = $auth->createUserFromGovBrPending($response); + if (!$user) { + GovBrAccountService::storePendingRegistration($pending); + $this->render('govbr-email', [ + 'config' => $config, + 'conflictEmail' => GovBrAccountService::extractEmailFromResponse($pending), + 'formAction' => $app->createUrl('auth', 'govbr-email'), + 'errors' => [i::__('Não foi possível criar o usuário. Tente novamente.', 'multipleLocal')], + 'triedEmail' => $new_email, + ]); + return; + } + + $auth->authenticateUser($user); + + if (method_exists('GovBrStrategy', 'verifyUpdateData')) { + \GovBrStrategy::verifyUpdateData($user, $response); + } + if (method_exists('GovBrStrategy', 'applySeal')) { + \GovBrStrategy::applySeal($user, $response); + } + + $app->applyHook('auth.successful'); + $redirect_url = $auth->getRedirectPath(); + unset($_SESSION['mapasculturais.auth.redirect_path']); + $app->redirect($redirect_url); + }); + /******* INIT LOCAL AUTH **********/ @@ -335,15 +439,58 @@ protected function _init() { $login = $app->auth->doLogin(); if ($login['success']) { + // Mantém auth.successful (AccountStatus, lastLogin, etc.), mas: + // 1) grava a sessão ANTES do trabalho pesado do hook + // 2) responde o JSON na hora + // 3) roda o hook no shutdown (após a resposta ao browser) + $redirectTo = $app->auth->getRedirectPath(); + + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + register_shutdown_function(function () use ($app) { + try { + ignore_user_abort(true); + set_time_limit(120); + if (function_exists('fastcgi_finish_request')) { + @fastcgi_finish_request(); + } + $app->applyHook('auth.successful'); + } catch (\Throwable $e) { + $app->log->error('auth.successful after login failed: ' . $e->getMessage()); + } + }); + $this->json([ - 'error' => false, - 'redirectTo' => $app->auth->getRedirectPath() + 'error' => false, + 'redirectTo' => $redirectTo + ]); + } else if (!empty($login['accountInTrash'])) { + $this->json([ + 'error' => true, + 'accountInTrash' => true, + 'profileName' => $login['profileName'], ]); } else { $this->errorJson($login['errors'], 200); } }); + $app->hook('POST(auth.confirmrestore)', function () use($app){ + /** + * @var \MapasCulturais\Controller $this + */ + + $restore = $app->auth->confirmRestoreAccount(); + + if ($restore['success']) { + $this->json(['error' => false]); + } else { + $this->errorJson($restore['errors'], 200); + } + }); + $app->hook('POST(auth.recover)', function () use($app){ /** * @var \MapasCulturais\Controller $this @@ -386,6 +533,22 @@ protected function _init() { } }); + $app->hook('POST(auth.doforcedpasswordchange)', function () use($app){ + /** + * @var \MapasCulturais\Controller $this + */ + + $this->requireAuthentication(); + + $doForcedPasswordChange = $app->auth->doForcedPasswordChange(); + + if ($doForcedPasswordChange['success']) { + $this->json(['error' => false]); + } else { + $this->errorJson($doForcedPasswordChange['errors'], 200); + } + }); + $app->hook('POST(auth.newpassword)', function () use($app){ /** * @var \MapasCulturais\Controller $this @@ -413,8 +576,46 @@ protected function _init() { $this->errorJson($adminchangeuserpassword['errors'], 200); } }); - - + + $app->hook('POST(auth.forcepasswordchange)', function () use ($app) { + /** + * @var \MapasCulturais\Controller $this + */ + + $forcePasswordChange = $app->auth->forcePasswordChange(); + + if ($forcePasswordChange['success']) { + $this->json(['error' => false]); + } else { + $this->errorJson($forcePasswordChange['errors'], 200); + } + }); + + $app->hook('auth.redirectUrl', function (&$redirect) use ($app) { + if ($app->auth->userMustChangePassword()) { + $redirect = $app->createUrl('auth', 'index'); + } + }); + + // Enquanto a troca de senha estiver pendente, o usuário fica preso em /autenticacao/ + // (form de troca, logout e endpoints auxiliares do auth). Qualquer outra rota + // (incluindo API) redireciona de volta — o controller auth fica liberado. + // O padrão segue o do módulo LGPD: callAction dispara ALL(controller.action):before, + // então ALL(<<*>>):before casa com qualquer controller/ação. + $app->hook('ALL(<<*>>):before,API(<<*>>):before,-ALL(auth.<<*>>):before,-API(auth.<<*>>):before', function () use ($app) { + /** @var \MapasCulturais\Controller $this */ + if (!$app->auth->userMustChangePassword()) { + return; + } + + if ($app->request->isAjax()) { + $app->halt(403, i::__('É necessário trocar a senha antes de continuar.', 'multipleLocal')); + } + + $app->redirect($app->createUrl('auth', 'index')); + }); + + $app->hook('panel.menu:after', function () use($app){ $active = $this->template == 'panel/my-account' ? 'class="active"' : ''; @@ -548,29 +749,32 @@ function validateRegisterFields() { if($config['enableLoginByCPF']) { // validate cpf - if(empty($cpf) || !$this->validateCPF($cpf)) { + if($config['requireCpf'] && !$this->validateCPF($cpf)) { array_push($errors['user']['cpf'], i::__('Por favor, informe um cpf válido.', 'multipleLocal')); $hasErrors = true; } - $foundAgent = []; - $metadataFieldCpf = $this->getMetadataFieldCpfFromConfig(); - $_cpf = implode("','", [$cpf, preg_replace('/[^0-9]/i', '', $cpf)]); - $foundAgent = $conn->fetchAll("SELECT * FROM agent_meta WHERE key IN ('{$metadataFieldCpf}', 'cpf') AND value IN ('{$_cpf}')"); - - // creates an array with agents with status == 1, because the user can have, for example, 3 agents, but 2 have status == 0 - $existAgent = []; - if($foundAgent){ - foreach ($foundAgent as $agentMeta) { - if($agentMeta->owner->status >= 0) { - $existAgent[] = $agentMeta; + if($this->validateCPF($cpf)) { + $foundAgent = []; + $metadataFieldCpf = $this->getMetadataFieldCpfFromConfig(); + + $_cpf = implode("','", [$cpf, preg_replace('/[^0-9]/i', '', $cpf)]); + $foundAgent = $conn->fetchAll("SELECT * FROM agent_meta WHERE key IN ('{$metadataFieldCpf}', 'cpf') AND value IN ('{$_cpf}')"); + + // creates an array with agents with status == 1, because the user can have, for example, 3 agents, but 2 have status == 0 + $existAgent = []; + if($foundAgent){ + foreach ($foundAgent as $agentMeta) { + if($agentMeta->owner->status >= 0) { + $existAgent[] = $agentMeta; + } } } - } - if(count($existAgent) > 0) { - array_push($errors['user']['cpf'], i::__('Este CPF já esta em uso. Tente recuperar a sua senha.', 'multipleLocal')); - $hasErrors = true; + if(count($existAgent) > 0) { + array_push($errors['user']['cpf'], i::__('Este CPF já esta em uso. Tente recuperar a sua senha.', 'multipleLocal')); + $hasErrors = true; + } } } @@ -811,16 +1015,123 @@ function adminchangeuserpassword() { } if (!$hasErrors) { + $user->setMetadata(self::$forcePasswordChangeMetadata, '0'); + $app->disableAccessControl(); $user->saveMetadata(true); $app->enableAccessControl(); $user->save(true); $app->em->flush(); - return [ + return [ 'success' => true ]; } else { - return [ + return [ + 'success' => false, + 'errors' => $errors + ]; + } + } + + /** + * Ação de admin: marca o usuário para ser obrigado a trocar a senha no próximo login + * bem-sucedido (o redirecionamento pós-login é tratado no hook 'auth.redirectUrl'). + */ + function forcePasswordChange() { + $app = App::i(); + + $errors = [ + 'forcePasswordChange' => [] + ]; + + $email = $app->request->post('email'); + $user = $this->getUserFromDB($email); + + $preconditionError = AccountLifecycleService::forcePasswordChangeError( + $app->user->is('admin'), + (bool) $user + ); + + if ($preconditionError === 'permission') { + array_push($errors['forcePasswordChange'], i::__('Você não tem permissão para executar esta ação.', 'multipleLocal')); + return [ + 'success' => false, + 'errors' => $errors + ]; + } + + if ($preconditionError === 'not_found') { + array_push($errors['forcePasswordChange'], i::__('Usuário não encontrado.', 'multipleLocal')); + return [ + 'success' => false, + 'errors' => $errors + ]; + } + + $user->setMetadata(self::$forcePasswordChangeMetadata, '1'); + + $app->disableAccessControl(); + $user->saveMetadata(true); + $app->enableAccessControl(); + $app->em->flush(); + + return [ + 'success' => true + ]; + } + + /** + * True se o usuário logado precisa trocar a senha antes de continuar usando o sistema + * (flag marcada por um admin via forcePasswordChange()). + */ + function userMustChangePassword() { + $app = App::i(); + $user = $app->user; + + return $user instanceof Entities\User + && AccountLifecycleService::mustChangePassword($user->getMetadata(self::$forcePasswordChangeMetadata)); + } + + /** + * Troca a senha do usuário já autenticado que está com a troca de senha obrigatória + * pendente. Diferente de changePassword(), não pede a senha atual: o próprio login + * bem-sucedido (com a senha antiga) já provou que o usuário é quem diz ser. + */ + function doForcedPasswordChange() { + $app = App::i(); + $user = $app->user; + + $hasErrors = false; + $errors = [ + 'password' => [], + ]; + + if (!AccountLifecycleService::canDoForcedPasswordChange($this->userMustChangePassword())) { + array_push($errors['password'], i::__('Não há troca de senha pendente para este usuário.', 'multipleLocal')); + return [ + 'success' => false, + 'errors' => $errors + ]; + } + + $newPassword = $app->request->post('new_password'); + $confirmNewPassword = $app->request->post('confirm_new_password'); + + $errors['password'] = $this->verifyPassowrds($newPassword, $confirmNewPassword); + if (!empty($errors['password'])) { + $hasErrors = true; + } else { + $user->setMetadata(self::$passMetaName, $this->hashPassword($newPassword)); + $user->setMetadata(self::$forcePasswordChangeMetadata, '0'); + } + + if (!$hasErrors) { + $user->save(true); + return [ + 'success' => true + ]; + } else { + return [ 'success' => false, 'errors' => $errors ]; @@ -850,17 +1161,18 @@ function changePassword() { $hasErrors = true; } else { $user->setMetadata($meta, $app->auth->hashPassword($newPassword)); - } + $user->setMetadata(self::$forcePasswordChangeMetadata, '0'); + } } else { array_push($errors['password'], i::__('Senha atual inválida.', 'multipleLocal')); $hasErrors = true; - } + } } else { array_push($errors['password'], i::__('Insira sua nova senha.', 'multipleLocal')); $hasErrors = true; } - + if (!$hasErrors) { $user->save(true); return [ @@ -1058,12 +1370,10 @@ function doLogin() { } } else { // LOGIN COM EMAIL - $query = new \MapasCulturais\ApiQuery ('MapasCulturais\Entities\User', ['@select' => 'id', 'email' => 'ILIKE(' . $emailToCheck . ')']); - if($user = $query->findOne()){ - unset($user['@entityType']); - array_filter($user); - $user = $app->repo("User")->findOneBy($user); - } + // getUserFromDB() usa uma query direta (sem o filtro de status do ApiQuery), então + // também encontra contas na lixeira: precisamos disso para mostrar o aviso de + // recuperação de conta em vez de simplesmente negar o login. + $user = $this->getUserFromDB($emailToCheck); } @@ -1076,14 +1386,14 @@ function doLogin() { array_push($errors['login'], i::__('Usuário ou senha inválidos.', 'multipleLocal')); $hasErrors = true; } else { - $accountIsActive = $user->getMetadata(self::$accountIsActiveMetadata); - if($config['userMustConfirmEmailToUseTheSystem']) { + $accountIsActive = $user->getMetadata(self::$accountIsActiveMetadata); + if($config['userMustConfirmEmailToUseTheSystem']) { if(isset($user) && $accountIsActive === '0' ) { array_push($errors['confirmEmail'], i::__('Verifique seu email para validar a sua conta.', 'multipleLocal')); $hasErrors = true; - } + } } - + $config = $this->_config; $timeBlockedloginAttemp = $config['timeBlockedloginAttemp']; //verifica se o metadata 'timeBlockedloginAttempMetadata' existe e é maior que o tempo de agora, se for, então o usuario ta bloqueado te tentar fazer login @@ -1101,8 +1411,18 @@ function doLogin() { $meta = self::$passMetaName; $savedPass = $user->getMetadata($meta); - + if (password_verify($pass, $savedPass)) { + if (AccountLifecycleService::shouldOfferTrashRestore($hasErrors, (int) $userToLogin->status, \MapasCulturais\Entity::STATUS_TRASH)) { + $this->middlewareLoginAttempts(true); + AccountLifecycleService::storePendingTrashRestore((int) $userToLogin->id); + + return AccountLifecycleService::buildAccountInTrashLoginResult( + $userToLogin->profile ? $userToLogin->profile->name : '', + $errors + ); + } + $this->middlewareLoginAttempts(true); $this->authenticateUser($userToLogin); } else { @@ -1110,14 +1430,130 @@ function doLogin() { array_push($errors['login'], i::__('Usuário ou senha inválidos.', 'multipleLocal')); $hasErrors = true; } - } + } return [ 'success' => !$hasErrors, 'errors' => $errors ];; } - + + /** + * Inverso do User::delete() do core: tira o usuário e as entidades que foram pra + * lixeira junto com ele. Fica no plugin (não no core) porque só o fluxo de + * recuperação de conta do MultipleLocalAuth usa isso. + */ + function restoreUserFromTrash(Entities\User $user, $flush = false) { + $app = App::i(); + $user->checkPermission('undelete'); + + $app->disableAccessControl(); + + $user->undelete($flush); + + foreach (AccountLifecycleService::relatedEntityTypesToRestore() as $entity_type) { + foreach ($user->$entity_type as $entity) { + if (AccountLifecycleService::entityShouldBeUndeleted((int) $entity->status, Entities\User::STATUS_TRASH)) { + $entity->undelete($flush); + } + } + } + + $app->enableAccessControl(); + + if ($flush) { + $app->em->flush(); + } + } + + /** + * Envia o email de confirmação para restaurar uma conta (e tudo que foi pra lixeira junto + * com ela) de um usuário cujo login foi barrado por estar na lixeira (ver o bloco + * 'accountInTrash' em doLogin()). A conta só sai da lixeira de fato quando o link do + * email é clicado (ver GET(auth.confirma-email)) — aqui só preparamos o token e avisamos. + */ + function confirmRestoreAccount() { + $app = App::i(); + + $errors = [ + 'restore' => [] + ]; + + $userId = AccountLifecycleService::getPendingTrashRestoreUserId(); + $user = $userId ? $app->repo("User")->find($userId) : null; + + $preconditionError = AccountLifecycleService::confirmRestoreError( + $userId, + $user, + \MapasCulturais\Entity::STATUS_TRASH + ); + + if ($preconditionError === 'expired') { + array_push($errors['restore'], i::__('Sessão expirada, faça login novamente.', 'multipleLocal')); + return [ + 'success' => false, + 'errors' => $errors + ]; + } + + if ($preconditionError === 'not_trash') { + AccountLifecycleService::clearPendingTrashRestore(); + array_push($errors['restore'], i::__('Esta conta não está mais com exclusão parcial.', 'multipleLocal')); + return [ + 'success' => false, + 'errors' => $errors + ]; + } + + // generate the token hash + $source = rand(3333, 8888); + $cut = rand(10, 30); + $string = $this->hashPassword($source); + $token = substr($string, $cut, 20); + + $app->disableAccessControl(); + $user->setMetadata(self::$tokenVerifyAccountMetadata, $token); + $user->setMetadata(self::$pendingTrashRestoreConfirmMetadata, '1'); + $user->saveMetadata(true); + $app->enableAccessControl(); + + AccountLifecycleService::clearPendingTrashRestore(); + + $baseUrl = $app->getBaseUrl(); + $site_name = $app->siteName; + + $mustache = new \Mustache_Engine(); + $content = $mustache->render( + file_get_contents( + __DIR__. + DIRECTORY_SEPARATOR.'views'. + DIRECTORY_SEPARATOR.'auth'. + DIRECTORY_SEPARATOR.'email-account-restored.html' + ), array( + "siteName" => $site_name, + "user" => $user->profile->name, + "urlToValidateAccount" => $baseUrl.'auth/confirma-email?token='.$token, + "baseUrl" => $baseUrl, + "urlSupportChat" => $this->_config['urlSupportChat'], + "urlSupportEmail" => $this->_config['urlSupportEmail'], + "urlSupportSite" => $this->_config['urlSupportSite'], + "textSupportSite" => $this->_config['textSupportSite'], + "urlImageToUseInEmails" => $this->getImageImageURl(), + ) + ); + + $app->createAndSendMailMessage([ + 'from' => $app->config['mailer.from'], + 'to' => $user->email, + 'subject' => sprintf(i::__('Confirme a recuperação da sua conta no %s', 'multipleLocal'), $site_name), + 'body' => $content + ]); + + return [ + 'success' => true + ]; + } + function doRegister() { $app = App::i(); $config = $app->_config; @@ -1161,6 +1597,15 @@ function doRegister() { $baseUrl = $app->getBaseUrl(); + if(!$user) { + $error['user']['createUser'] = i::__('Não foi possível criar o usuário. Entre em contato com suporte', 'multipleLocal'); + + return [ + 'success' => false, + 'errors' => $error + ]; + } + //ATENÇÃO !! Se for necessario "padronizar" os emails com header/footers, é necessario adapatar o 'mustache', e criar uma mini estrutura de pasta de emails em 'MultipleLocalAuth\views' $mustache = new \Mustache_Engine(); $site_name = $app->siteName; @@ -1195,7 +1640,8 @@ function doRegister() { $user->{self::$tokenVerifyAccountMetadata} = $token; $user->{self::$accountIsActiveMetadata} = '0'; $app->modules['LGPD']->acceptTerms($app->request->post('slugs'), $user); - $user->save(); + $user->save(true); + $app->enableAccessControl(); @@ -1296,7 +1742,7 @@ protected function _validateResponse(){ // verifica se a resposta é um erro if (array_key_exists('error', $response)) { - $app->flash('auth error', 'Opauth returns error auth response'); + // $app->flash('auth error', 'Opauth returns error auth response'); } else { /** * Auth response validation @@ -1327,14 +1773,41 @@ public function _getAuthenticatedUser() { if (is_object($this->_authenticatedUser)) { return $this->_authenticatedUser; } - - if (isset($_SESSION['multipleLocalUserId'])) { + + $govBrResponse = null; + if ($this->_validateResponse()) { + $response = $this->_getResponse(); + if (GovBrAccountService::isGovBrProvider($response['auth']['provider'] ?? null)) { + $govBrResponse = $response; + } + } + + // Em callback Gov.br, não reutilizar sessão de outro CPF. + if (!$govBrResponse && isset($_SESSION['multipleLocalUserId'])) { $user_id = $_SESSION['multipleLocalUserId']; $user = App::i()->repo("User")->find($user_id); return $user; } $user = null; + if ($govBrResponse) { + $app = App::i(); + $cpf = GovBrAccountService::extractCpfFromResponse($govBrResponse); + if (!empty($cpf)) { + $metadataFieldCpf = $this->getMetadataFieldCpfFromConfig(); + $agent_meta = $app->repo('AgentMeta')->findOneBy(["key" => $metadataFieldCpf, "value" => $cpf]); + if (empty($agent_meta)) { + $digits = preg_replace('/\D+/', '', $cpf); + $agent_meta = $app->repo('AgentMeta')->findOneBy(["key" => $metadataFieldCpf, "value" => $digits]); + } + if (!empty($agent_meta)) { + $user = $agent_meta->owner->user; + } + } + // Gov.br: sem fallback por e-mail (evita hijack de conta). + return $user; + } + if($this->_validateResponse()){ $app = App::i(); $response = $this->_getResponse(); @@ -1372,7 +1845,16 @@ public function processResponse(){ // e ainda não existe um usuário no sistema $user = $this->_getAuthenticatedUser(); $response = $this->_getResponse(); + if(!$user){ + if (GovBrAccountService::isGovBrProvider($response['auth']['provider'] ?? null) + && GovBrAccountService::hasEmailConflictOnCreate($response) + ) { + GovBrAccountService::storePendingRegistration($response); + App::i()->redirect(App::i()->createUrl('auth', 'govbr-email')); + return false; + } + $user = $this->createUser($response); $profile = $user->profile; @@ -1398,6 +1880,19 @@ public function processResponse(){ return false; } } + + /** + * Cria usuário a partir do fluxo pendente Gov.br (após e-mail alternativo). + * Expõe o createUser final do core sem alterar o core. + */ + public function createUserFromGovBrPending(array $response) + { + try { + return $this->createUser($response); + } catch (\Throwable $e) { + return null; + } + } @@ -1424,24 +1919,33 @@ function authenticateUser(Entities\User $user) { $this->_setAuthenticatedUser($user); $_SESSION['multipleLocalUserId'] = $user->id; } - - protected function _createUser($response) { + + protected function _createUser($response) + { $app = App::i(); + /** @var \MapasCulturais\Connection $conn */ + $conn = $app->em->getConnection(); + $app->disableAccessControl(); $config = $this->_config; $user = null; - if($provider_class = $response['auth']['provider']."Strategy"){ - if(method_exists($provider_class, "newAccountCheck")){ - if($user = $provider_class::newAccountCheck($response)){ + if ($provider_class = $response['auth']['provider'] . "Strategy") { + if (method_exists($provider_class, "newAccountCheck")) { + if ($user = $provider_class::newAccountCheck($response)) { $agent = $user->profile; } } } - if(!$user){ + if ($user) { + return $user; + } + + try { + $app->em->beginTransaction(); // cria o usuário $user = new Entities\User; $user->authProvider = $response['auth']['provider']; @@ -1449,73 +1953,79 @@ protected function _createUser($response) { $user->email = $response['auth']['info']['email']; $app->em->persist($user); - + // cria um agente do tipo user profile para o usuário criado acima $agent = new Entities\Agent($user); - if(isset($response['auth']['info']['name'])){ + if (isset($response['auth']['info']['name'])) { $agent->name = $response['auth']['info']['name']; - } - elseif(isset($response['auth']['info']['first_name']) && isset($response['auth']['info']['last_name'])){ + } elseif (isset($response['auth']['info']['first_name']) && isset($response['auth']['info']['last_name'])) { $agent->name = $response['auth']['info']['first_name'] . ' ' . $response['auth']['info']['last_name']; - } - elseif(isset($response['auth']['agentData']['name'])){ + } elseif (isset($response['auth']['agentData']['name'])) { $agent->name = $response['auth']['agentData']['name']; - } - else{ + } else { $agent->name = ''; } - - if(isset($response['auth']['info']['phone_number'])){ - $metadataFieldPhone = $this->getMetadataFieldPhone(); - $metadataFieldPhone = $this->getMetadataFieldPhone(); - $metadataFieldPhone = $this->getMetadataFieldPhone(); + + if (isset($response['auth']['info']['phone_number'])) { + $metadataFieldPhone = $this->getMetadataFieldPhone(); + $metadataFieldPhone = $this->getMetadataFieldPhone(); + $metadataFieldPhone = $this->getMetadataFieldPhone(); $agent->$metadataFieldPhone = $response['auth']['info']['phone_number']; } - if(isset($response['auth']['agentData']['shortDescription'])){ + if (isset($response['auth']['agentData']['shortDescription'])) { $agent->shortDescription = $response['auth']['agentData']['shortDescription']; } - if(isset($response['auth']['agentData']['terms:area'])){ + if (isset($response['auth']['agentData']['terms:area'])) { $agent->terms['area'] = $response['auth']['agentData']['terms:area']; } - if(isset($response['auth']['info']['phone_number'])){ - $metadataFieldPhone = $this->getMetadataFieldPhone(); + if (isset($response['auth']['info']['phone_number'])) { + $metadataFieldPhone = $this->getMetadataFieldPhone(); $agent->setMetadata($metadataFieldPhone, $response['auth']['info']['phone_number']); } //cpf - $cpf = (isset($response['auth']['info']['cpf']) && $response['auth']['info']['cpf'] != "") ? $this->mask($response['auth']['info']['cpf'],'###.###.###-##') : null; - if(!empty($cpf)){ - $metadataFieldCpf = $this->getMetadataFieldCpfFromConfig(); + $cpf = (isset($response['auth']['info']['cpf']) && $response['auth']['info']['cpf'] != "") ? $this->mask($response['auth']['info']['cpf'], '###.###.###-##') : null; + if (!empty($cpf)) { + $metadataFieldCpf = $this->getMetadataFieldCpfFromConfig(); $agent->$metadataFieldCpf = $cpf; } $agent->status = (int) $config['statusCreateAgent'] ?? '0'; $agent->emailPrivado = $user->email; - - $agent->save(); - $app->em->flush(); + $agent->save(true); + $user->profile = $agent; $user->save(true); + if(!$conn->fetchScalar("SELECT profile_id FROM usr where id = {$user->id}")) { + throw new Exception("Error create agent"); + } + $user->createPermissionsCacheForUsers([$user]); $agent->createPermissionsCacheForUsers([$user]); - } - - $app->enableAccessControl(); - $redirectUrl = $agent->status == Agent::STATUS_DRAFT ? $agent->editUrl : $this->getRedirectPath(); - $app->applyHookBoundTo($this, 'auth.createUser:redirectUrl', [&$redirectUrl]); - if ($redirectUrl) { - $this->_setRedirectPath($redirectUrl); + $app->em->commit(); + + $app->enableAccessControl(); + $redirectUrl = $agent->status == Agent::STATUS_DRAFT ? $agent->editUrl : $this->getRedirectPath(); + $app->applyHookBoundTo($this, 'auth.createUser:redirectUrl', [&$redirectUrl]); + + if ($redirectUrl) { + $this->_setRedirectPath($redirectUrl); + } + + return $user; + + } catch (\Throwable $th) { + $app->em->rollback(); + return null; } - - return $user; } function mask($val, $mask) { diff --git a/README.md b/README.md index e910937..e6a0c0c 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,169 @@ # MultipleLocalAuth -Plugin que implementa um método de autenticação local para o Mapas Culturais, em conjunto com login via redes sociais. - -## Instalação e Configuração - -Faça download ou clone do plugin e coloque a pasta MultipleLocalAuth no pasta dos plugins do Mapas Culturais. - -No arquivo de configuração do Mapas Culturais, config.php, você deve: - -1. Ativar o plugin -2. Configurar MultipleLocalAuth como seu Provider de autenticação -3. Configurar as chaves das redes sociais - -Para ativar o plugin, adicione na sua array de Plugins: -``` +Plugin de autenticação para o Mapas Culturais que combina login local (e-mail e CPF) com múltiplas estratégias sociais (Google, Facebook, LinkedIn, Twitter, Login Cidadão, Gov.br, Decidim etc.), regras de senha configuráveis e proteção contra abuso. + +## Recursos principais +- Cadastro local com fluxo multi-etapas, validação de CPF e aceite de termos LGPD. +- Login por e-mail ou CPF, com limite de tentativas e bloqueio temporário automático. +- Confirmação de conta por e-mail, recuperação de senha com token e troca de senha pelo painel. +- Troca de senha forçada por admin: no próximo login o usuário fica preso em `/autenticacao/` até definir uma nova senha. +- Recuperação de conta na lixeira: login com senha correta em conta soft-deleted oferece confirmação por e-mail antes de restaurar usuário e entidades relacionadas. +- Integração com Google reCAPTCHA v2 (visível) para login, cadastro e recuperação. +- Autenticação social via Opauth (Google, Facebook, LinkedIn, Twitter, Login Cidadão, Gov.br, Decidim) com mapeamento automático de dados. +- Atualização opcional de avatar e metadados ao autenticar via Gov.br ou Decidim. +- Componentes Vue (`login`, `create-account`, `change-password`, `password-strongness`) prontos para a Base V2. + +## Instalação +1. Faça download/clonagem deste repositório e coloque a pasta `MultipleLocalAuth` em `protected/application/plugins/` do Mapas Culturais. +2. Garanta que o módulo `LGPD` esteja habilitado, pois o cadastro consome `LGPD::acceptTerms`. +3. Instale dependências do Mapas Culturais (este plugin não adiciona dependências externas além das que já vêm com o core/opauth). + +## Configuração +Edite `config.php` do Mapas Culturais: + +```php 'plugins' => [ // ... outros plugins 'MultipleLocalAuth' => [ 'namespace' => 'MultipleLocalAuth', ], ], -``` - -Para definir este plugin como seu método de autenticação, defina a configuraço *auth.provider*: -``` -'auth.provider' => '\MultipleLocalAuth\Provider', -``` -Finalmente, defina a configuração *auth.config* para definir as estratégias utilizadas e as chaves dos serviços: +'auth.provider' => \MultipleLocalAuth\Provider::class, -``` 'auth.config' => [ - - //SALT da senha do usuario - 'salt' => 'LT_SECURITY_SALT_SECURITY_SALT_SECURITY_SALT_SECURITY_SALT_SECU', - - 'timeout' => '24 hours', - - //url de suporte por chat para ser enviado nos emails - 'urlSupportChat' => 'https://www.google.com', - - //url de suporte por email para ser enviado nos emails - 'urlSupportEmail' => 'https://www.google.com', - - //url do site de suporte para ser enviado nos emails - 'urlSupportSite' => 'https://www.google.com', - - //url dos termos de uso para utilizar a plataforma - 'urlTermsOfUse' => 'https://www.google.com', - - //url de uma imagem para ser enviado como plano de fundo nos emails - 'urlImageToUseInEmails' => 'https://mapacultural.juazeiro.ce.gov.br/files/project/1561/file/963893/blob-3d922310b0a1eb1c16791a06023f56df.png', - - //Habilita registro e login através do CPF - 'enableLoginByCPF' => true, - - //apelido do metadata que será salvo o campo CPF - 'metadataFieldCPF' => 'documento', - - //Regra para saber se o usuario deve ou não confiar o email para poder utilizar o sistema - 'userMustConfirmEmailToUseTheSystem' => false, - - //Regra de força de senha - Ter no mínimo 1 letra maiúscula - 'passwordMustHaveCapitalLetters' => true, - - //Regra de força de senha - Ter no mínimo 1 letra minúscula - 'passwordMustHaveLowercaseLetters' => true, - - //Regra de força de senha - Ter no mínimo 1 caractere especial - 'passwordMustHaveSpecialCharacters' => true, - - //Regra de força de senha - Ter no mínimo 1 caractere numérico - 'passwordMustHaveNumbers' => true, - - //Regra de força de senha - Ter no mínimo n caracteres - 'minimumPasswordLength' => 6, - - //Configuração de GOOGLE Recaptcha - 'google-recaptcha-secret' => '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe', - 'google-recaptcha-sitekey' => '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', - - //Tempo da sessao do usuario em segundos - 'sessionTime' => 7200, - - //Limite de tentativas não sucedidas de login antes de bloquear o usuario por X minutos - 'numberloginAttemp' => '5', - - //Tempo de bloqueio do usuario em segundos, após romper limites de tentativas não sucedidas - 'timeBlockedloginAttemp' => '900', - - //Estratégias de autenticação - 'strategies' => [ - 'Facebook' => array( - 'app_id' => 'SUA_APP_ID', - 'app_secret' => 'SUA_APP_SECRET', - 'scope' => 'email' - ), - 'LinkedIn' => array( - 'api_key' => 'SUA_API_KEY', - 'secret_key' => 'SUA_SECRET_KEY', - 'redirect_uri' => URL_DO_SEU_SITE . '/autenticacao/linkedin/oauth2callback', - 'scope' => 'r_emailaddress' - ), - 'Google' => array( - 'client_id' => 'SEU_CLIENT_ID', - 'client_secret' => 'SEU_CLIENT_SECRET', - 'redirect_uri' => URL_DO_SEU_SITE . '/autenticacao/google/oauth2callback', - 'scope' => 'email' - ), - 'Twitter' => array( - 'app_id' => 'SUA_APP_ID', - 'app_secret' => 'SUA_APP_SECRET', - ), - ] + // ajuste conforme as tabelas abaixo ], ``` + +### Opções gerais +| Chave | Descrição | Padrão | Variável `.env` | +| --- | --- | --- | --- | +| `salt` | Salt usado pelo Opauth | `env('AUTH_SALT')` | `AUTH_SALT` | +| `timeout` | Tempo máximo da sessão OAuth | `24 hours` | `AUTH_TIMEOUT` | +| `loginOnRegister` | Autenticar automaticamente após cadastro | `false` | `AUTH_LOGIN_ON_REGISTER` | +| `enableLoginByCPF` | Permite login/cadastro via CPF | `true` | `AUTH_LOGIN_BY_CPF` | +| `requireCpf` | Exige CPF no cadastro | `true` | `AUTH_REQUIRED_CPF` | +| `metadataFieldCPF` | Campo de metadata que armazena CPF | `documento` | `AUTH_METADATA_FIELD_DOCUMENT` | +| `metadataFieldPhone` | Campo de metadata que armazena telefone | `telefone1` | `AUTH_METADATA_FIELD_PHONE` | +| `userMustConfirmEmailToUseTheSystem` | Exige validação por e-mail antes do uso | `false` | `AUTH_EMAIL_CONFIRMATION` | +| `sessionTime` | Duração da sessão (segundos) | `7200` | `AUTH_SESSION_TIME` | +| `statusCreateAgent` | Status default do agente criado | `Agent::STATUS_ENABLED` | `STATUS_CREATE_AGENT` | + +### Comunicação e suporte +| Chave | Uso | Padrão | Variável `.env` | +| --- | --- | --- | --- | +| `urlSupportChat` | Link incluído nos e-mails | `''` | `AUTH_SUPPORT_CHAT` | +| `urlSupportEmail` | Link de contato por e-mail | `''` | `AUTH_SUPPORT_EMAIL` | +| `urlSupportSite` | URL geral de suporte | `''` | `AUTH_SUPPORT_SITE` | +| `textSupportSite` | Texto exibido com o link | `''` | `AUTH_SUPPORT_TEXT` | +| `urlImageToUseInEmails` | Imagem de fundo para e-mails | `null` | `AUTH_EMAIL_IMAGE` | +| `urlTermsOfUse` | URL dos termos de uso | `auth/termos-e-condicoes` | `LINK_TERMOS` | + +### Regras de senha +| Chave | Descrição | Padrão | Variável `.env` | +| --- | --- | --- | --- | +| `passwordMustHaveCapitalLetters` | Exigir letra maiúscula | `true` | `AUTH_PASS_CAPITAL_LETTERS` | +| `passwordMustHaveLowercaseLetters` | Exigir letra minúscula | `true` | `AUTH_PASS_LOWERCASE_LETTERS` | +| `passwordMustHaveSpecialCharacters` | Exigir caractere especial | `true` | `AUTH_PASS_SPECIAL_CHARS` | +| `passwordMustHaveNumbers` | Exigir número | `true` | `AUTH_PASS_NUMBERS` | +| `minimumPasswordLength` | Tamanho mínimo da senha | `6` | `AUTH_PASS_LENGTH` | + +### Proteção contra abuso +| Chave | Descrição | Padrão | Variável `.env` | +| --- | --- | --- | --- | +| `numberloginAttemp` | Tentativas antes do bloqueio | `5` | `AUTH_NUMBER_ATTEMPTS` | +| `timeBlockedloginAttemp` | Tempo de bloqueio (segundos) | `900` | `AUTH_BLOCK_TIME` | + +### Google reCAPTCHA v2 +| Chave | Descrição | Padrão | +| --- | --- | --- | +| `google-recaptcha-secret` | Secret da integração | `env('GOOGLE_RECAPTCHA_SECRET')` | +| `google-recaptcha-sitekey` | Site key usada no front | `env('GOOGLE_RECAPTCHA_SITEKEY')` | + +Se ambas as chaves estiverem ausentes, o captcha é desativado. + +### Estratégias de autenticação +Cada estratégia pode receber `visible => bool` para controlar se o botão aparece na interface. + +#### Google +- `client_id`, `client_secret`, `redirect_uri`, `scope` (`email profile` por padrão). + +#### Facebook +- `app_id`, `app_secret`, `scope` (default `email`). + +#### LinkedIn +- `api_key`, `secret_key`, `redirect_uri`, `scope` (default `r_emailaddress`). + +#### Twitter +- `app_id`, `app_secret`. (Fluxo direto do Opauth). + +#### Login Cidadão +- `client_id`, `client_secret`, `auth_endpoint`, `token_endpoint`, `userinfo_endpoint`, `redirect_uri`, `scope`. + +#### Gov.br +- `client_id`, `client_secret`, `scope`, `auth_endpoint`, `token_endpoint`, `userinfo_endpoint`, `redirect_uri`. +- `state_salt`, `code_verifier`, `code_challenge`, `code_challenge_method` para PKCE. +- `applySealId` (opcional): selo aplicado ao agente autenticado. +- `dic_agent_fields_update`: mapa de campos que podem ser atualizados automaticamente (JSON, ex: `{"name": "full_name"}`). +- `menssagem_authenticated`: mensagem exibida quando o usuário já autenticou via Gov.br. +- Identidade estável: matching **somente por CPF** (`sub`); `authUid` usa `sub` (não `jti`). Sem fallback por e-mail (evita hijack). +- Se o e-mail do Gov.br já existir em `usr.email` (único/obrigatório no core), a criação é interrompida e o usuário informa outro e-mail em `auth/govbr-email`. +- `verifyUpdateData` não sobrescreve perfil cujo CPF diverge do token Gov.br. + +#### Decidim +- `client_id`, `client_secret`, `auth_endpoint`, `token_endpoint`, `userinfo_endpoint`, `redirect_uri`, `scope`. +- Atualiza automaticamente avatar do agente com a imagem fornecida. + +Você pode adicionar ou remover estratégias conforme necessário; qualquer estratégia Opauth disponível no diretório do plugin pode ser configurada. + +## Fluxos e endpoints +- `GET auth.index`: renderiza o componente de login (ou o formulário de troca forçada, se `forcePasswordChange` estiver pendente). +- `GET auth.register`: fluxo multi-etapas de cadastro. +- `GET auth.recover`: formulário para solicitar redefinição de senha. +- `GET auth.confirma-email`: valida o token enviado por e-mail e ativa a conta; se houver `pendingTrashRestoreConfirm`, também restaura a conta da lixeira. +- `POST auth.validate`: validação assíncrona do primeiro passo do cadastro. +- `POST auth.register`: criação de conta (gera agente, token de verificação e envia e-mail). +- `POST auth.login`: autenticação local (com bloqueio por tentativas via metadata). Conta na lixeira + senha correta → resposta `accountInTrash` (sem autenticar). +- `POST auth.confirmrestore`: após o aviso de conta na lixeira, envia e-mail de confirmação de recuperação (token + flag `pendingTrashRestoreConfirm`). +- `POST auth.recover` / `POST auth.dorecover`: solicitação e conclusão da recuperação de senha. +- `POST auth.changepassword` / `POST auth.newpassword`: alteração de senha logado ou via token (limpa `forcePasswordChange`). +- `POST auth.forcepasswordchange`: admin marca o usuário para trocar a senha no próximo login. +- `POST auth.doforcedpasswordchange`: usuário autenticado com troca pendente define a nova senha (sem pedir a senha atual). +- `POST auth.adminchangeuseremail` / `POST auth.adminchangeuserpassword`: rotinas administrativas (acessos protegidos; alteração de senha por admin também limpa `forcePasswordChange`). +- `GET auth.passwordvalidationinfos`: retorna as regras de senha atuais para o front-end. +- `GET|POST auth.govbr-email`: coleta e-mail alternativo quando o e-mail do Gov.br já está em uso (criação de conta). + +Metadados de usuário usados nesses fluxos: `forcePasswordChange`, `pendingTrashRestoreConfirm` (mais `tokenVerifyAccount` no e-mail de restore). + +## Testes +Regras isoladas em services (`GovBrAccountService`, `AccountLifecycleService`) têm testes unitários em `tests/` — cobrem CPF/e-mail único do Gov.br, troca de senha forçada e recuperação de conta na lixeira: + +```bash +cd plugins/MultipleLocalAuth +php composer.phar install # ou: composer install +./vendor/bin/phpunit +``` + +## Componentes que acompanham o plugin +- `components/login`: formulário de login com reCAPTCHA, recuperação de senha, aviso de conta na lixeira e botões sociais. +- `components/create-account`: esteira de cadastro com validações de senha, CPF e aceite de termos LGPD. +- `components/change-password`: formulário para troca de senha no painel e fluxo de troca forçada pós-login. +- `components/password-strongness`: barra de força da senha, reutilizada em cadastro e redefinição. + +Todos os componentes carregam textos a partir de `components/*/texts.php`, permitindo tradução personalizada. + +## Personalização +- E-mails: templates Mustache em `views/auth/email-to-validate-account.html`, `views/auth/email-resert-password.html` e `views/auth/email-account-restored.html` (confirmação de recuperação de conta na lixeira). Você pode copiar/adaptar mantendo as variáveis esperadas. +- Telas: `views/auth/*.php` rendem os componentes Vue; é possível sobrescrever esses arquivos em um tema customizado. +- Estilos: CSS compilado em `assets/css/plugin-MultiplLocalAuth.css`. O SCSS-fonte está em `assets-src/sass/`. +- Traduções: arquivos `.po` em `translations/` (domínio `multipleLocal`). + +## Boas práticas +- Configure o cron/serviço de fila de e-mail do Mapas Culturais antes de habilitar a confirmação por e-mail. +- Ajuste `metadataFieldCPF`/`metadataFieldPhone` para corresponder ao schema de metadados do seu deployment. +- Revise as mensagens carregadas via `textSupportSite`, `urlSupport*` para garantir contato adequado ao usuário. +- Gere URLs de callback das estratégias sociais com HTTPS e defina-as nos painéis dos provedores. + +--- +Mantemos este documento atualizado a partir do código-fonte do plugin. Contribuições são bem-vindas! \ No newline at end of file diff --git a/assets-src/sass/4-components/_c-change-password.scss b/assets-src/sass/4-components/_c-change-password.scss index db30d14..04384ff 100644 --- a/assets-src/sass/4-components/_c-change-password.scss +++ b/assets-src/sass/4-components/_c-change-password.scss @@ -1,7 +1,7 @@ .change-password { display: flex; flex-direction: column; - gap: 5px; + gap: 8px; padding: 16px 0 13px; &__title { @@ -10,9 +10,26 @@ font-weight: 600; } + &__pending-warning { + align-items: center; + background: var(--mc-alert-100, #fff3cd); + border-radius: 4px; + color: var(--mc-alert-700, #7a5b00); + display: flex; + font-size: 13px; + gap: 8px; + padding: 8px 12px; + + .iconify { + flex-shrink: 0; + font-size: 16px; + } + } + &__password { align-items: center; display: flex; + flex-wrap: wrap; gap: 18px; &--fakePassword { @@ -48,6 +65,29 @@ } } + &__force { + margin-top: 4px; + + &--action { + align-items: center; + color: var(--mc-helper-500); + cursor: pointer; + display: inline-flex; + gap: 6px; + + .iconify { + font-size: 15px; + } + + .label { + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 19px; + } + } + } + &__modal { .modal-content { max-width: 570px; diff --git a/components/change-password/script.js b/components/change-password/script.js index bd57308..8e3123b 100644 --- a/components/change-password/script.js +++ b/components/change-password/script.js @@ -65,6 +65,7 @@ app.component('change-password', { if (dataReturn.error) { this.throwErrors(dataReturn.data); } else { + this.entity.forcePasswordChange = '0'; this.messages.success('Senha alterada com sucesso!'); this.cancel(modal); } @@ -72,6 +73,22 @@ app.component('change-password', { } }, + async forcePasswordChange(modal) { + let api = new API(); + let data = { + 'email': this.entity.email, + } + await api.POST($MAPAS.baseURL+"autenticacao/forcepasswordchange", data).then(response => response.json().then(dataReturn => { + if (dataReturn.error) { + this.throwErrors(dataReturn.data); + } else { + this.entity.forcePasswordChange = '1'; + this.messages.success('Na próxima vez que este usuário entrar com a senha atual, ele será levado a trocá-la.'); + modal.close(); + } + })); + }, + cancel(modal) { this.newPassword = ''; this.confirmNewPassword = ''; diff --git a/components/change-password/template.php b/components/change-password/template.php index 8ec74b8..db12a05 100644 --- a/components/change-password/template.php +++ b/components/change-password/template.php @@ -14,6 +14,11 @@
= i::__('Na próxima vez que este usuário entrar com a senha atual, ele será obrigado a trocá-la.') ?>
+= i::__('Confirma?') ?>
+ + + + + +| + |
+
+
+
+
+ + + +
|
+ + |
+ = i::__('O e-mail informado pelo Gov.br já está vinculado a outra conta neste mapa cultural.', 'multipleLocal') ?> + + (= htmlspecialchars($conflictEmail) ?>) + +
++ = i::__('Para criar a sua conta, informe outro e-mail. Este e-mail será o da sua conta e deve ser único.', 'multipleLocal') ?> +
+ + +