Autenticação
LDAP, 2FA (e-mail e Google Authenticator), rate limiting de login e reset de senha por token single-use.
Autenticação
O login padrão do MAD (LoginForm, em app/control/Iam/LoginForm.php) é um
MadComponent único que orquestra, num só fluxo reativo, autenticação por
senha, seleção de unidade (multiunit), aceite de termos e segundo fator —
sem recarregar a página entre etapas. Esta página documenta as peças de
segurança por trás dele: rate limiting, LDAP, 2FA e reset de senha. Para a
mecânica de sessão/middleware (mad.auth, PermissionGate) ver
Auth no portal público.
O pipeline de login
LoginForm::doLogin() roda, nesta ordem, a cada submit (e cada modal que o
sucede reentra no mesmo método com os argumentos pendentes):
- reCAPTCHA (se
google_recaptchaestiver ligado nas preferências). - Rate limit por IP + login (
LoginRateLimiter) — bloqueia antes mesmo de tocar o banco. - Credenciais via
App\Service\Iam\AuthenticationService::authenticate(). - Unidade (se
multiunit=1e houver mais de uma, ourequest_unit_after_login=1) — abre modallogin-unit. - Termos de uso (se
require_terms=1e o usuário ainda não aceitou) — modallogin-terms. - 2FA (se o usuário tem
two_factor_enabled='Y') — modallogin-2fa-emailoulogin-2fa-googleconformetwo_factor_type. - Grava sessão, registra
Access/AccessNotification(ver Observabilidade), emite o token do Copilot embed (semad.ai.enabled) e redireciona.
Cada etapa pendente guarda pendingLogin/pendingPassword/pendingUnitId/
pendingLangId como props públicas do componente (serializadas no
mad_state criptografado) — os modais reentram no pipeline sem reenviar a
senha em claro pela URL nem depender do POST original.
// App\Service\Iam\AuthenticationService::authenticate()
$user = User::validate($login); // nunca revela se o login existe (anti-enumeração)
$ini = mad_app_config();
if (!empty($ini['permission']['auth_service']) && class_exists($ini['permission']['auth_service'])) {
$service = $ini['permission']['auth_service'];
$service::authenticate($login, $password); // hook plugável — ver LDAP abaixo
} else {
User::authenticate($login, $password); // password_verify() contra mad_iam_user
}
Rate limiting de login
App\Service\Iam\LoginRateLimiter é independente do RateLimiter facade do
Laravel — usa um storage próprio em arquivo (tmp/login_attempts/*.json,
0600, com .htaccess defensivo) para não depender de cache configurado.
- Chave:
sha256(ip|login)— rastreia o par IP+login, não só o IP. Um atacante distribuído (botnet) ainda esbarra no limite por usuário-alvo. - 5 falhas em 15 minutos bloqueia por 15 minutos a partir da 1ª falha.
- Sucesso de login limpa o contador do par.
use App\Service\Iam\LoginRateLimiter;
$key = LoginRateLimiter::keyFor($ip, $login);
LoginRateLimiter::check($key); // lança Exception se bloqueado
try {
AuthenticationService::authenticate($login, $password, false);
} catch (\Throwable $e) {
LoginRateLimiter::registerFailure($key);
throw $e;
}
LoginRateLimiter::registerSuccess($key);
Os outros fluxos sensíveis a brute-force usam o RateLimiter nativo do
Laravel com as próprias janelas: reenvio de código 2FA por e-mail (3 a
cada 15 min, por IP+login pendente) e solicitação de reset de senha (3 a
cada 15 min por IP+identificador e um teto global de 5/hora por
destinatário — trava flood distribuído trocando IP).
Autenticação LDAP (ponto de extensão)
App\Service\Iam\LdapAuthenticationService é o hook de autenticação
externa: quando permission.auth_service aponta para uma classe, ela
substitui inteiramente a checagem de senha local (User::authenticate()).
class LdapAuthenticationService
{
public static function authenticate($user, $password)
{
$ldap = parse_ini_file('app/config/ldap.ini');
$ds = ldap_connect($ldap['server'], $ldap['port']);
if ($ds && @ldap_bind($ds, $user . '@' . $ldap['domain'], $password)) {
return true;
}
throw new \Exception(_t('Invalid LDAP credentials'));
}
}
Não é plugada por padrão.
permission.auth_servicenão vem definida emconfig/mad.php— é preciso declará-la explicitamente apontando paraApp\Service\Iam\LdapAuthenticationService::class(ou a sua própria implementação) para o login passar a validar contra um diretório LDAP/AD.
A implementação embutida é um ponto de partida, não um cliente LDAP
endurecido: lê host/porta/domínio de app/config/ldap.ini (arquivo que você
precisa criar — não existe um exemplo versionado), não inicia TLS
(ldap_start_tls()), não define timeout de conexão e devolve sempre a mesma
mensagem genérica em caso de falha (o que é bom para anti-enumeração, mas
também esconde erros de configuração — vale logar a exceção original antes
de relançar a genérica). Em produção, prefira estender essa classe (ou trocar
por uma própria) adicionando ldap_start_tls(), ldap_set_option() com
timeout, e mova as credenciais do diretório para config/mad.php/.env em
vez do .ini solto.
O contrato exigido por AuthenticationService é só este: um método estático
authenticate($login, $password) que lança em caso de falha e retorna
normalmente (qualquer valor) em caso de sucesso — User::validate($login)
continua resolvendo o registro local do usuário (grupos, unidades,
permissões) independentemente de onde a senha foi validada.
Two-factor authentication (2FA)
Dois métodos, ligados independentemente via preferências do sistema
(mad_sys_preference: 2fa_by_email, 2fa_by_google_auth) e configurados
por usuário em Configurações → aba Conta (SettingsForm,
/app/conta/configuracoes). O estado do usuário fica em 3 colunas de
mad_iam_user: two_factor_enabled (Y/N), two_factor_type
(email|google_authenticator) e two_factor_secret (só usado pelo TOTP).
Por e-mail
App\Service\Iam\TwoFactorEmailService gera um código numérico de 6 dígitos
com random_int() (CSPRNG — não rand()), guarda na sessão (não no
banco) com timestamp e contador de tentativas, e envia por e-mail via
MailService.
- Expira em 10 minutos.
- Máximo 5 tentativas de verificação — estourou, invalida o código e força reenvio.
- Comparação com
hash_equals()(tempo constante, evita timing attack). - Single-use: sucesso remove o código da sessão.
TwoFactorEmailService::generateAndSendEmailCode($user->email, $user->name);
// ...
if (!TwoFactorEmailService::verifyEmailCode($code)) {
throw new \Exception(_t('Invalid verification code. Request a new code.'));
}
Google Authenticator (TOTP)
GoogleAuthenticator (app/lib/util/GoogleAuthenticator.php, classe
global, sem namespace) implementa TOTP (RFC 6238) puro — sem dependência de
extensão php-otp, só hash_hmac('SHA1', ...). Usa BaconQrCode (já uma
dependência do projeto) só para desenhar o QR code como SVG inline (data
URL), sem chamada a serviço externo.
$auth = new \GoogleAuthenticator();
$secret = $auth->createSecret(); // base32, 16 chars
$qrSrc = $auth->getQRCode($user->email, $issuer, $secret); // data:image/svg+xml;base64,...
// ... usuário escaneia e digita o código de 6 dígitos ...
if (!\GoogleAuthenticator::verifyCode($secret, $code)) {
throw new \Exception(_t('Invalid verification code. Request a new code.'));
}
verify()aceita uma janela de±1período de 30s ($discrepancy, default 1) para tolerar drift de relógio entre cliente e servidor.- Comparação também via
hash_equals(). - Brute-force de um código de 6 dígitos é mitigado no chamador: o login já
passou pelo
LoginRateLimiter, e o cadastro do 2FA exige sessão autenticada — não há endpoint anônimo que aceite tentativas ilimitadas de código TOTP. - O segredo só é persistido em
two_factor_secretdepois de o usuário provar posse do app autenticador (onVerify2FAemSettingsForm) — durante o setup ele vive só na proptwoFactorSecretdo componente.
Reset de senha por token
App\Service\Iam\PasswordResetTokenService substituiu um esquema antigo de
JWT-na-URL (que vazava o login do usuário em logs de servidor/Referer e
era reaproveitável até expirar) por tokens single-use em filesystem:
- Token bruto: 32 bytes aleatórios (
random_bytes) em hex (64 chars). - Armazenado com hash (
sha256) —tmp/password_resets/<hash>.json; um vazamento do diretório não dá ao atacante o token original. - TTL de 1 hora por padrão (
PasswordResetTokenService::DEFAULT_TTL = 3600);issue()aceita override — e o fluxo real usa 3 horas (RequestPasswordResetForm::RESET_TTL = 3 * 3600), não o default. consume()remove o arquivo após uso — single-use de verdade.revokeAllForUser()invalida todos os tokens pendentes de um usuário depois de um reset bem-sucedido (links antigos em trânsito param de funcionar).
// RequestPasswordResetForm::onRequest()
$token = PasswordResetTokenService::issue((int) $user->id, self::RESET_TTL); // 3 * 3600
$link = url(MadRoutes::toFriendlyUrl('index.php?class=PasswordResetForm') . '?token=' . urlencode($token));
// ... envia $link por e-mail via MailService ...
// PasswordResetForm::onSave()
$userId = PasswordResetTokenService::validate($token); // null se inválido/expirado
// ... troca a senha (password_hash/PASSWORD_BCRYPT) ...
PasswordResetTokenService::consume($token);
PasswordResetTokenService::revokeAllForUser($userId);
A resposta de RequestPasswordResetForm é sempre a mesma, exista o
usuário/e-mail ou não (anti-enumeração) — inclusive quando o pedido é
descartado silenciosamente por estourar o rate limit.
Notificação de novo login (opcional)
App\Service\Log\AccessNotificationLogService é um aviso por e-mail
("você acabou de logar; não reconhece isso? procure o suporte"), ligado por
general.notification_login. registerLogin() enfileira a notificação
(tabela mad_log_access_notification); sendNotificationLogin() é quem de
fato envia e limpa a fila — chamado via CLI
(php cmd.php "class=SystemAccessNotificationLogService&method=sendNotificationLogin&static=1")
ou pelo seu próprio scheduler. Não é gatilho automático a cada login — o
envio é assíncrono/batch por desenho.
Ver também
- Observabilidade (logs) — onde cada login
(
mad_log_access) e tentativa negada acabam registrados. - Tracing (MadTrace) — captura de exceptions e performance, incluindo falhas de autenticação não tratadas.
- Auth no portal público —
mad.auth,session(),PermissionGate. - Hardening de filtros e ordenação (grid) —
allowlists de coluna/operador/
ORDER BYnas listagens autenticadas. - MadValidationException —
asModal(), usado pelos forms de login/reset para exibir erros de validação.