outubro 2008
D S T Q Q S S
« mai   abr »
 1234
567891011
12131415161718
19202122232425
262728293031  

Autenticação segura utilizando AJAX

Um grande problema que sempre existiu foi a autenticação via formulário, onde o usuário envia o seu nome de usuário e senha de forma “texto puro” na rede, possibilitando que quaisquer pessoa que tenha acesso ao tráfego web ou à aplicação no servidor tenha acesso a estas informações. O ideal é que um sistema não tenha acesso a senha do usuário, e normalmente armazena um hash criptográfico da mesma, mas mesmo assim, ele necessita receber a senha para validar a autenticação.

Para evitar esse problema, pode-se utilizar um serviço HTTP com criptografia (HTTPS), mas mesmo assim ainda não resolve o problema de a aplicação possuir a senha do usuário. Pensando nisso, desenvolvi uma solução utilizando AJAX, onde a autenticação é segura através de utilização de um “desafio”.

Para implementar este método de autenticação onde nenhuma informação sigilosa é trafegada em forma de texto puro na rede (nem mesmo hash da senha), devemos seguir os seguintes passos:

  1. O cliente requisita um desafio ao servidor utilizando AJAX, para manter o conteúdo atual do formulário e página;
  2. O servidor retorna um desafio, que é uma string randômica e única. Este desafio fica armazenado no servidor tendo uma validade de alguns segundos;
  3. O cliente calcula um hash com a concatenação do desafio com o hash da senha informada pelo usuário. O resultado então é enviado para o servidor juntamente com o nome do usuário;
  4. O servidor verifica na base qual é o último desafio enviado para o cliente (atendendo o tempo de expiração), concatena o mesmo com o hash da senha que possui no banco de dados, e compara o hash resultante desta concatenação com o valor enviado pelo cliente (que é o mesmo hash, do desafio concatenado com o hash da senha informada pelo usuário);
  5. Coincidindo os valores, a autenticação é bem sucedida.

Este processo faz com que a senha nunca seja exposta, nem mesmo o hash dela, pois é enviado apenas um hash do desafio (texto randômico e único gerado pelo servidor) com o hash real da senha. Como a cada autenticação o desafio criado pelo servidor é diferente, o resultado do hash será sempre diferente, fazendo com que seja impossível se obter alguma informação da senha.

O maior problema deste método de autenticação é que a senha na base de dados deve utilizar um único sistema de hash padronizado, ou serem mantidas em “texto puro”, o que é extremamente desaconselhado. Já vi algumas bases de autenticação que utilizavam SHA1 e MD5 misturados. Neste tipo de base este sistema de autenticação não funcionaria.

O processo é um pouco difícil de se entender, então vamos implementar passo a passo.

Primeiro precisamos escolher um método de hash a ser utilizado tanto no cliente quanto no servidor para criar a autenticação e o hash da senha em si. Existem diversas bibliotecas para JavaScript, sendo as que eu recomendo:

Utilizei por muito tempo o MD5, mas atualmente substitui pelo SHA2 de 256 bits por ser mais seguro e ser suportado por todas linguagens que utilizo. Por isto, o exemplo feito aqui utilizará ele como base. Você pode alterar para outros algorítimos facilmente se desejar.

O hash gerado pelo SHA2 de 256 bits possui 32 bytes em sua forma binária, mas utilizaremos ele em hexadecimal, que ocupa 64 bytes, para evitar problemas com caracteres especiais. Lembre-se que você deve armazenar o hash da senha no servidor utilizando o mesmo método utilizado no navegador do cliente.

Primeiramente, vamos criar uma função para retornar um objeto XMLHTTP:

function createXMLHTTP ()
{
  // Navegadores Mozilla/Safari:
  if ( window.XMLHttpRequest)
  {
    xmlHttpReq = new XMLHttpRequest ();
    xmlHttpReq.overrideMimeType ( 'text/xml');
  } else {
    // Navegadores IE:
    if ( window.ActiveXObject)
    {
      // O IE utiliza Msxml2.XMLHTTP:
      var ActiveXName = 'Msxml2.XMLHTTP';

      // O IE 5.5 ou superior utiliza Microsoft.XMLHTTP:
      if ( navigator.appVersion.indexOf ( 'MSIE 5.5') >= 0)
      {
        ActiveXName = 'Microsoft.XMLHTTP';
      }

      // Tenta instanciar:
      try
      {
        xmlHttpReq = new ActiveXObject ( ActiveXName);
      }

      // Avisa que o ActiveX está desabilitado:
      catch ( e)
      {
        alert ( 'Suporte a script no ActiveX deve ser habilitado.');
        return false;
      }
    } else {
      // Quaisquer outro navegador não é suportado:
      alert ( 'O seu navegador não é suportado por este aplicativo.');
      return false;
    }
  }

  return xmlHttpReq;
}

Então implementamos o código JavaScript que cria o objeto XMLHTTP (AJAX) e requisita o desafio ao servidor:

function getChallenge ( username)
{
  var xmlHttpReq = false;

  // Instancia um novo objeto XMLHTTP:
  xmlHttpReq = createXMLHTTP ();
  if ( ! xmlHttpReq)
  {
    // Retorna falso:
    return false;
  }

  // Insere os valores no objeto e envia:
  xmlHttpReq.open ( 'POST', 'auth.php', false);
  xmlHttpReq.setRequestHeader ( 'Content-Type', 'application/x-www-form-urlencoded; charset=utf-8');
  xmlHttpReq.send ( 'usuario=' + escape ( username));

  // Verifica se ocorreu erro (código HTTP diferente de 200):
  if ( xmlHttpReq.status != 200)
  {
    // Mostra uma mensagem de erro:
    alert ( "Ocorreu um erro ao autenticar no sistema.");

    // Retorna falso:
    return false;
  }

  // Verifica se o valor retornado é válido (possui 64 bytes):
  if ( xmlHttpReq.responseText.length != 64)
  {
    // Mostra uma mensagem de erro:
    alert ( "Ocorreu um erro ao autenticar no sistema.");

    // Retorna falso:
    return false;
  }

  // Retorna o valor randômico recebido do servidor:
  return xmlHttpReq.responseText;
}

Precisamos também de uma função para enviar o resultado da autenticação para o servidor:

function doLogin ( username, hash)
{
  var xmlHttpReq = false;

  // Instancia um novo objeto XMLHTTP:
  xmlHttpReq = createXMLHTTP ();

  // Insere os valores no objeto e envia:
  xmlHttpReq.open ( 'POST', 'auth.php', false);
  xmlHttpReq.setRequestHeader ( 'Content-Type', 'application/x-www-form-urlencoded; charset=utf-8');
  xmlHttpReq.send ( 'usuario=' + escape ( username) + '&hash=' + escape ( hash));

  // Verifica se ocorreu erro (código HTTP diferente de 200):
  if ( xmlHttpReq.status != 200)
  {
    // Mostra uma mensagem de erro:
    alert ( "Ocorreu um erro ao autenticar no sistema.");

    // Retorna falso:
    return false;
  }

  // Retorna o resultado:
  return xmlHttpReq.responseText;
}

Finalmente, precisamos de uma função para gerenciar a autenticação, ou seja, requisitar o desafio, criar o hash resultante do desafio e hash da senha e enviar ao servidor. Para isto, criamos a função de autenticação em JavaScript:

function handleLogin ( form)
{
  // Recupera informações da autenticação do formulário informado:
  username = document.getElementById('login_username').value;
  password = document.getElementById('login_password').value;

  // Retorna caso não seja informado o usuário:
  if ( ! username)
  {
    return false;
  }

  // Desabilita o botão de envio de formulário para evitar envio duplicado:
  document.getElementById('ok_button').disabled = true;
  document.getElementById('ok_button').value = 'Autenticando...';

  // Requisita o desafio:
  challenge = getChallenge ( username);

  // Envia requisição de autenticação:
  login = doLogin ( username, hex_sha256 ( hex_sha256 ( password) + challenge), challenge);

  // Verifica resultado:
  if ( login == '1')
  {
    // Autenticação bem sucedida:
    alert ( 'Autenticado como "' + username + '"!');
  } else {
    // Autenticação falhou. Exibe mensagem de erro:
    alert ( 'Falha na autenticação!');

    // Limpa o campo de senha:
    document.getElementById('login_password').value = "";

    // Seta o foco do navegador para o campo de senha:
    document.getElementById('login_password').focus ();
  }

  // Habilita o botão de autenticação do sistema:
  document.getElementById('ok_button').disabled = false;
  document.getElementById('ok_button').value = 'Autenticar';

  // Retorna falso:
  return false;
}

No lado do servidor criamos o código PHP para realizar a geração de desafios e autenticação no sistema. O desafio gerado será uma string de 64 bytes, um hash SHA256 de um número randômico. Este será o arquivo “auth.php”:

<?php
// Primeiro, precisamos conectar ao banco de dados:
if ( ! $_sql_id = @mysql_connect ( "localhost", "myuser", "mypass"))
{
  die ( "Erro: Não foi possível conectar ao banco de dados.");
}

// Selecionamos a base de dados padrão da conexão:
if ( ! @mysql_select_db ( "mydb", $_sql_id))
{
  die ( "Erro: Não foi possível selecionar a base de dados.");
}

// Verificamos se a requisição possui senha (se não possui, é uma requisição de desafio):
if ( empty ( $_POST["senha"]))
{
  // Criamos o hash randômico de 64 bytes:
  $desafio = bin2hex ( mhash ( MHASH_SHA256, uniqid ( rand ())));

  // Verificamos se o usuário informado existe, e recuperamos a informação do banco de dados:
  if ( ! $result = @mysql_query ( "SELECT * FROM `usuarios` WHERE `Usuario` = '" . mysql_real_escape_string ( $_POST["usuario"], $_sql_id) . "'", $_sql_id))
  {
    // Atenção: Não devemos nunca informar se um usuário existe ou não, por isto, retornaremos um hash como se a operação fosse bem sucedida:
    die ( $desafio);
  }

  // Verificamos se foi localizada a informação:
  if ( mysql_num_rows ( $result) != 1)
  {
    // Leia o comentário do "if" anterior.
    die ( $desafio);
  }

  // Recuperamos a informação para um array e liberamos a memória:
  $usuario = mysql_fetch_array ( $result);
  mysql_free_result ( $result);

  // Guardamos o desafio gerado na base de dados:
  @mysql_query ( "INSERT INTO `sessoes` (`Usuario`, `Desafio`, `Validade`, `IP`) VALUES (" . (int) $usuario["ID"] . ", '" . mysql_real_escape_string ( $desafio, $_sql_id) . "', " . ( time () + 30) . ", INET_ATON('" . mysql_real_escape_string ( $_SERVER["REMOTE_ADDR"], $_sql_id) . "'))", $_sql_id);

  // Retorna-se o desafio (mesmo que a gravação na base de dados tenha ocorrido erro):
  die ( $desafio);
} else {
  // É uma autenticação completa, ou seja, enviado usuário e hash da senha concatenada com o desafio.
  // Primeiramente, verificamos o último desafio enviado para o IP do cliente que ainda esteja válido:
  if ( ! $result = @mysql_query ( "SELECT * FROM `sessoes` WHERE `IP` = INET_ATON('" . mysql_real_escape_string ( $_SERVER["REMOTE_ADDR"], $_sql_id) . "') AND `Validade` >= " . time () . " ORDER BY ID DESC LIMIT 0, 1", $_sql_id))
  {
    // Retornamos sempre autenticação inválida (nenhuma mensagem de erro):
    die ( "0");
  }
  // Verifica se foi retornado algum valor:
  if ( mysql_num_rows ( $result) != 1)
  {
    // Retornamos sempre autenticação inválida (nenhuma mensagem de erro):
    die ( "0");
  }

  // Armazenamos a informação do desafio válido para concatenarmos com o hash da senha:
  $desafio = mysql_fetch_array ( $result);
  mysql_free_result ( $result);

  // Requisitamos a informação do usuário informado:
  if ( ! $result = @mysql_query ( "SELECT * FROM `usuarios` WHERE `Usuario` = '" . mysql_real_escape_string ( $_POST["usuario"], $_sql_id) . "'", $_sql_id))
  {
    die ( "0");
  }
  // Verificamos se foi retornado um valor válido:
  if ( mysql_num_rows ( $result) != 1)
  {
    // Retorna autenticação inválida (não informa que o usuário não existe):
    die ( "0");
  }
  $usuario = mysql_fetch_array ( $result);
  mysql_free_result ( $result);

  // Agora que temos a informação do último desafio válido enviado para este cliente, e a informação do usuário, fazemos a verificação do hash enviado:
  if ( bin2hex ( mhash ( MHASH_SHA256, $usuario["Senha"] . $desafio["Desafio"])) != $_POST["hash"])
  {
    // Se a comparação falhou, a senha informada é inválida:
    die ( "0");
  }

  // Temos a confirmação da senha enviada através do hash concatenado com o desafio. Então eliminamos o desafio da base de dados e criamos a sessão do usuário.

  // Criamos o hash randômico de 64 bytes para a cookie:
  $cookie = bin2hex ( mhash ( MHASH_SHA256, uniqid ( rand ())));

  // Alteramos o registro contendo o desafio para cookie:
  if ( ! @mysql_query ( "UPDATE `sessoes` SET `Desafio` = '', `Cookie` = '" . mysql_real_escape_string ( $cookie, $_sql_id) . "', `Validade` = " . ( time () + 3600) . " WHERE `IP` = INET_ATON('" . mysql_real_escape_string ( $_SERVER["REMOTE_ADDR"], $_sql_id) . "') AND `Desafio` = '" . mysql_real_escape_string ( $desafio["Desafio"], $_sql_id) . "' AND `Usuario` = " . (int) $usuario["ID"], $_sql_id))
  {
    die ( "0");
  }

  // Retornamos a cookie para o cliente juntamente com a confirmação de autenticação:
  setcookie ( "myapp", $cookie, 0, dirname ( $_SERVER["PHP_SELF"]), $_SERVER["HTTP_HOST"]);
  die ( "1");
}
?>

Para a base de dados, utilizaremos a seguinte estrutura de tabelas:

CREATE TABLE `sessoes` (
  `ID`        bigint unsigned NOT NULL auto_increment,
  `Usuario`   bigint unsigned NOT NULL,
  `Desafio`   char(64) NOT NULL,
  `Cookie`    char(64) NOT NULL,
  `Validade`  int unsigned NOT NULL,
  `IP`        int unsigned NOT NULL,
  PRIMARY KEY (`ID`),
  KEY         `Cookie` (`Cookie`),
  KEY         `ID` (`ID`),
  KEY         `Usuario` (`Usuario`),
  KEY         `Validade` (`Validade`),
  KEY         `IP` (`IP`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

CREATE TABLE `usuarios` (
  `ID`        bigint unsigned NOT NULL auto_increment,
  `Usuario`   varchar(255) NOT NULL,
  `Senha`     char(64) NOT NULL,
  PRIMARY KEY (`ID`),
  KEY         `ID` (`ID`),
  KEY         `Usuario` (`Usuario`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

Esta tabela será utilizada tanto para arquivar o hash randômico, quanto para arquivar o hash de sessão, que é uma string randômica única gerada para o cliente como chave da sessão, e enviada através de cookie. Sempre que uma cookie do sistema é recebida, verificamos nesta tabela o IP do cliente, a cookie e se a mesma ainda está dentro do tempo de expiração da sessão do sistema, sendo que desta informação temos o ID do usuário, que é armazenado na tabela de usuários. Este procedimento é muito similar ao utilizado internamente pelo PHP para manipulação de variáveis de sessão, mas implementado manualmente. As variáveis de sessão não podem ser consideradas seguras, visto que são armazenadas em “texto puro” dentro de arquivos temporários que são compartilhados no servidor com outras aplicações, ou seja, se você gravar dados sensíveis nas variáveis de sessão, outros aplicativos podem vir a ter acesso a esta informação. Veja o artigo Segurança de Sessões em PHP para maiores detalhes. A entrada nunca poderá ter os valores de Desafio e Cookie simultaneamente, assim que autenticado, o registro perde o valor de ‘Desafio’ e cria-se um valor para Cookie da sessão.

Para o teste, adicionei dois usuários. O usuário “usuario” com a senha “teste” e o usuário “admin” com a senha “admin”:

 INSERT INTO `usuarios` (`Usuario`, `Senha`) VALUES ('usuario', '46070d4bf934fb0d4b06d9e2c46e346944e322444900a435d7d9a95e6d7435f5');
 INSERT INTO `usuarios` (`Usuario`, `Senha`) VALUES ('admin', '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918');

Para testar o sistema de autenticação, criei este exemplo.

Caso você deseje, pode fazer o download dos códigos deste artigo aqui.

Este código está sendo disponibilizado sob licença GPL versão 3. Caso você utilize este processo de autenticação em algum software, por favor me informe qual o sistema implementado, linguagem utilizada e se possuo permissão para postar uma referencia ao seu software neste artigo.

Envie uma resposta

 

 

 

Você pode utilizar estas tags HTML

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>