Docs›Segurança & Observabilidade›Autenticação
Segurança & Observabilidade

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):

  1. reCAPTCHA (se google_recaptcha estiver ligado nas preferências).
  2. Rate limit por IP + login (LoginRateLimiter) — bloqueia antes mesmo de tocar o banco.
  3. Credenciais via App\Service\Iam\AuthenticationService::authenticate().
  4. Unidade (se multiunit=1 e houver mais de uma, ou request_unit_after_login=1) — abre modal login-unit.
  5. Termos de uso (se require_terms=1 e o usuário ainda não aceitou) — modal login-terms.
  6. 2FA (se o usuário tem two_factor_enabled='Y') — modal login-2fa-email ou login-2fa-google conforme two_factor_type.
  7. Grava sessão, registra Access/AccessNotification (ver Observabilidade), emite o token do Copilot embed (se mad.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_service não vem definida em config/mad.php — é preciso declará-la explicitamente apontando para App\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 ±1 perí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_secret depois de o usuário provar posse do app autenticador (onVerify2FA em SettingsForm) — durante o setup ele vive só na prop twoFactorSecret do 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