Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f4b12b5
se actualiza archivo de traducción
kterva Feb 28, 2024
c192a23
faz validação de obrigatoriedade de cpf
LimaSystem Dec 26, 2024
e93e821
corrige validação de cpf
rafaelchavesfreitas Dec 27, 2024
92e2ce5
Faz com que os cadastros criados apartir da nova versão do Mapas seja…
erleibiazzio Jan 3, 2025
2d13cba
Merge pull request #74 from kterva/feature/login-basev2
erleibiazzio Mar 17, 2025
0575435
Merge branch 'feature/login-basev2'
erleibiazzio Mar 17, 2025
7dc4137
actualización archivo español
kterva Apr 7, 2025
26a724e
Merge pull request #82 from kterva/feature/login-basev2
rafaelchavesfreitas May 7, 2025
d011fad
Merge branch 'feature/login-basev2'
erleibiazzio May 7, 2025
96a6622
Implementa transaction no momento da criação de usuario para evitar q…
erleibiazzio Aug 18, 2025
cec3590
Faz melhorias no processo de criação de usuários para evitar que os m…
erleibiazzio Aug 18, 2025
0dc3c18
Melhora mensagem de erro ao criar usuario
erleibiazzio Aug 18, 2025
04dc78d
Implementa autenticação com Decidim no ambiente
erleibiazzio Nov 11, 2025
4819b39
Atualiza Documentação
erleibiazzio Nov 11, 2025
1afa354
Ajusta para poder alterar o texto do botão do Decidim
erleibiazzio Nov 11, 2025
fddd14f
Ajusta template para mostrar corretamente o texto do botão Decidim
erleibiazzio Nov 11, 2025
e67cdca
Adiciona flush no salvamento do user
israelmelo Nov 28, 2025
050ff3d
Ajusta plugin para que solicite as taxonomias obrigatórias no momento…
erleibiazzio Jan 6, 2026
14605bc
Garante que o hook auth.successful rode tambem no endpoint POST log…
erleibiazzio Jan 6, 2026
7ffc4f7
Atualiza texto explicative do porque é solicitado o CPF
erleibiazzio Mar 20, 2026
a36d283
Corrige hijack de conta no Gov.br vinculando login só por CPF.
erleibiazzio Jul 31, 2026
c07f36b
Corrige 500s do fluxo Gov.br pós-hijack e estabiliza o login local.
erleibiazzio Jul 31, 2026
6c6c98b
Registra metadados de troca forçada de senha e recuperação de conta n…
erleibiazzio Aug 6, 2026
119a0ad
Extrai regras de force password e restore da lixeira para AccountLife…
erleibiazzio Aug 6, 2026
3017896
Implementa troca forçada de senha e recuperação de conta na lixeira n…
erleibiazzio Aug 6, 2026
f8d76e4
Adiciona template de e-mail para confirmar a recuperação de conta na …
erleibiazzio Aug 6, 2026
46e75fa
Adiciona UI de aviso e confirmação quando o login encontra conta na l…
erleibiazzio Aug 6, 2026
0f97bb2
Adiciona UI de troca de senha obrigatória após login com forcePasswor…
erleibiazzio Aug 6, 2026
6cd2051
Adiciona testes unitários do AccountLifecycleService para force passw…
erleibiazzio Aug 6, 2026
4ed363e
Documenta no README force password, restore da lixeira, endpoints e t…
erleibiazzio Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
126 changes: 126 additions & 0 deletions AccountLifecycleService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace MultipleLocalAuth;

/**
* Regras de ciclo de vida de conta isoladas do Provider (testáveis sem App):
* - troca de senha forçada por admin;
* - login barrado para conta na lixeira + confirmação de restauração via e-mail.
*/
class AccountLifecycleService
{
public const FORCE_PASSWORD_CHANGE_METADATA = 'forcePasswordChange';
public const PENDING_TRASH_RESTORE_CONFIRM_METADATA = 'pendingTrashRestoreConfirm';
public const PENDING_TRASH_RESTORE_SESSION_KEY = 'pendingTrashRestoreUserId';

/** Espelha MapasCulturais\Entity::STATUS_TRASH */
public const STATUS_TRASH = -10;

/** Entidades que User::delete() manda para a lixeira junto com o usuário. */
public const RELATED_ENTITY_TYPES = ['agents', 'spaces', 'projects', 'opportunities', 'events'];

public static function mustChangePassword(?string $forcePasswordChangeMetadata): bool
{
return $forcePasswordChangeMetadata === '1';
}

/**
* Pré-condições da ação admin forcePasswordChange().
* Retorna código de erro ou null se ok: 'permission' | 'not_found' | null
*/
public static function forcePasswordChangeError(bool $isAdmin, bool $userExists): ?string
{
if (!$isAdmin) {
return 'permission';
}

if (!$userExists) {
return 'not_found';
}

return null;
}

/**
* Pré-condição de doForcedPasswordChange(): precisa haver flag pendente.
*/
public static function canDoForcedPasswordChange(bool $mustChangePassword): bool
{
return $mustChangePassword;
}

/**
* Login com senha correta + conta na lixeira: não autentica; pede confirmação de restore.
*/
public static function shouldOfferTrashRestore(bool $hasOtherErrors, int $userStatus, int $trashStatus = self::STATUS_TRASH): bool
{
return !$hasOtherErrors && $userStatus === $trashStatus;
}

/**
* @return array{success: false, accountInTrash: true, profileName: string, errors: array}
*/
public static function buildAccountInTrashLoginResult(string $profileName, array $errors = []): array
{
return [
'success' => 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]);
}
}
245 changes: 245 additions & 0 deletions Decidim/DecidimStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<?php

use MapasCulturais\App;
use Curl\Curl;

class DecidimStrategy extends OpauthStrategy{

/**
* Compulsory config keys, listed as unassociative arrays
*/
public $expects = ['client_id', 'client_secret', 'auth_endpoint'];
/**
* Optional config keys, without predefining any default values.
*/
public $optionals = ['redirect_uri', 'scope', 'response_type', 'register_form_action', 'register_form_method'];
/**
* Optional config keys with respective default values, listed as associative arrays
* eg. array('scope' => '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;
}

}
Loading