Sistema de Login com AJAX e PHP – Blindado
O tema de hoje é relacionado a segurança, vou demonstrar como construir um sistema de login com AJAX e PHP onde serão implementadas diversas validações.
Objetivo desse post é desenvolver um formulário de login usando AJAX, as informações de usuário e senha serão enviadas para um script PHP, vamos efetuar as devidas validações.
Segurança em aplicações WEB
Segurança é sempre um assunto delicado ainda mais quando se trata de aplicações WEB, só temos uma certeza nesse assunto “não existe aplicações 100% seguras”.
Porém podemos sempre dificultar a vida do invasor com técnicas de validações e segurança, no blog já publiquei um infográfico com dicas de segurança relacionadas ao PHP, vale a pena ler.
Práticas de Segurança na Validação PHP
- Verificar a origem da requisição, não é 100% confiável mas vale como medida de prevenção para evitar requisições externas
- Validar se o E-mail e Senha foram preenchidos
- Validar se o formato do E-mail está correto
- Verificar se o IP já excedeu o número de tentativas erradas seguidamente e bloqueiar por 30 minutos o IP, prevenir Brute Force
- Consulta no banco de dados parametrizada, prevenindo SQL Injection
- Validação da Senha usando API Password Hashing, mais segurança na Senha
- Gravar o log de tentativas não validadas, conhecer as tentativas de ataque
Construindo sistema de login com AJAX
Pré – requisitos:
Banco de Dados
Tabela “tab_usuario” possui campos básicos, observem o tamanho do campo “senha VARCHAR(255)” nossa primeira medida de segurança, vamos gerar o hash das senhas usando a API Password Hashing que foi implementada a partir do PHP 5.5, a própria documentação oficial sugere criar campos com tamanho (255) pois o algorítimo pode sofrer mudanças com o tempo aumentando o tamanho da string gerada.
Tabela “tab_log_tentativa” tem como objetivo gravar todas as tentativas de login inválidas e a origem das tentativas HTTP_REFERER, com isso podemos validar a quantidade de tentativas de login, onde chegando a três tentativas erradas vamos bloquear o login para aquele IP por 30 minutos. Com essa tabela também é importante conhecer os tipos de tentativas de login inválidas que nossa aplicação está sofrendo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
CREATE DATABASE IF NOT EXISTS devwilliam; USE devwilliam; CREATE TABLE tab_usuario( id INT AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(100), email VARCHAR(100), senha VARCHAR(300), status CHAR(1) ); CREATE TABLE tab_log_tentativa( id INT AUTO_INCREMENT PRIMARY KEY, ip VARCHAR(15), email VARCHAR(100), senha VARCHAR(300), origem VARCHAR(300), bloqueado CHAR(3), data_hora timestamp NULL DEFAULT CURRENT_TIMESTAMP, ); |
Formulário de Login HTML
O formulário de login é bem simples, no topo temos uma instrução PHP verificando se já existe um session “logado”, se existir redireciona o usuário para página “home.php”.
No formulário todos os dados serão enviados via AJAX, as instruções jQuery estão no script “custom.js” que chamo no final da página de login.
index.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
<?php session_start(); if(isset($_SESSION['logado']) && $_SESSION['logado'] == 'SIM'): header("Location: home.php"); endif; ?> <!DOCTYPE html> <html> <head> <title>Exemplo Login com AJAX</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"> <style type="text/css"> #login-alert{ display: none; } .margin-top-pq{ margin-top: 10px; } .margin-top-md{ margin-top: 25px; } .margin-bottom-md{ margin-bottom: 25px; } .padding-top-md{ padding-top: 30px; } </style> </head> <body> <div class="container"> <div id="loginbox" class="mainbox col-md-7 col-md-offset-3 col-sm-8 col-sm-offset-2 margin-top-md"> <div class="panel panel-primary" > <div class="panel-heading"> <div class="panel-title">Login - DevWilliam</div> </div> <div class="panel-body padding-top-md" > <div id="login-alert" class="alert alert-danger col-sm-12"> <span class="glyphicon glyphicon-exclamation-sign"></span> <span id="mensagem"></span> </div> <form id="login-form" class="form-horizontal" role="form" action="login.php" method="post"> <div class="input-group margin-bottom-md"> <span class="input-group-addon"><i class="glyphicon glyphicon-user"></i></span> <input type="email" class="form-control" id="email" name="email" required placeholder="Informe seu E-mail"> </div> <div class="input-group margin-bottom-md"> <span class="input-group-addon"><i class="glyphicon glyphicon-lock"></i></span> <input type="password" class="form-control" id="senha" name="senha" required placeholder="Informe sua Senha"> </div> <div class="form-group margin-top-pq"> <div class="col-sm-12 controls"> <button type="button" class="btn btn-primary" name="btn-login" id="btn-login"> Entrar </button> </div> </div> </form> </div> </div> </div> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js"></script> <script src="js/custom.js"></script> </body> </html> |
JavaScript (jQuery)
Nesse script envio os dados e-mail e senha usando AJAX, observem que a requisição espera que a resposta do servidor sempre venha no formato JSON (dataType: ‘json’), com isso recebo o código e a mensagem de erro para exibir no formulário de login. Conheça mais sobre requisições AJAX com jQuery.
Se o script PHP enviar código “1” então redireciono o usuário para página “home.php“, senão exibo a mensagem de erro para manter o usuário informado sobre quantas tentativas ele tem antes de bloquear o IP.
custom.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
$('document').ready(function(){ $("#btn-login").click(function(){ var data = $("#login-form").serialize(); $.ajax({ type : 'POST', url : 'login.php', data : data, dataType: 'json', beforeSend: function() { $("#btn-login").html('Validando ...'); }, success : function(response){ if(response.codigo == "1"){ $("#btn-login").html('Entrar'); $("#login-alert").css('display', 'none') window.location.href = "home.php"; } else{ $("#btn-login").html('Entrar'); $("#login-alert").css('display', 'block') $("#mensagem").html('<strong>Erro! </strong>' + response.mensagem); } } }); }); }); |
Validar Login no PHP
Nesse script a coisa fica séria, aqui vamos usar as 7 práticas de segurança que citei no início desse post!
No início defino 2 constantes, “TENTATIVAS_ACEITAS” informa quantidade de vezes seguidas que será aceito tentativas de login “não validadas”, “MINUTOS_BLOQUEIO” informa quantos minutos o IP ficará bloqueado caso o usuário tenha excedido o quantidade de tentativas.
A classe “conexao.php” que é chamada no require é a mesma que usei no post PDO – conexão seguindo padrão Singleton no PHP.
Todas os erros encontrados nas validações abaixo finalizam o script e retornam o código “0” com uma mensagem informativa em JSON para a requisição AJAX.
Dica 1 – Verificar com HTTP_REFERER a origem da requisição, essa informação pode não existir e para os invasores mais avançados é possível alterar no header do request, mas para ataques leves ainda é válida. No meu caso verifico se a requisição veio do formulário de login “http://localhost/login/index.php”, se for diferente disso então finalizo o script.
Dica 2 – Validar se o E-mail e a Senha foram preenchidos, caso não então retorno a mensagem e finalizo o script. Ainda é possível encontrar formulários de login onde a consulta retorna dados mesmo com valores “vazio”, porque o desenvolvedor esqueceu uma linha em “branco” na tabela de usuários.
Dica 3 – Validar se o formato do E-mail está correto usando a função nativa “FILTER_VALIDATE_EMAIL”, é muito comum os usuários esquecerem parte do endereço de e-mail.
Dica 4 – Consultar na tabela “tab_log_tentativa” se o IP do usuário está bloqueado (bloqueado = “SIM”) na data atual por excesso de tentativas, a própria instrução SQL calcula e retorna a quantidade de minutos que já decorreram do bloqueio para verificar se é menor que 30 minutos, então encerra o script enviando a mensagem.
Dica 5 – Consultar se o E-mail existe na tabela “tab_usuario”, sempre usando PDO e trabalhando com consultas parametrizadas para evitar SQL Injection, leia mais posts sobre PDO..
Dica 6 – Se o e-mail seja encontrado então validar se o “Hash” da senha gravada no banco é igual a senha digitada no formulário, observem que para comparação usamos uma função específica da API Password Hashing “password_verify()”, conheça mais sobre a nova API Password Hashing no PHP 5.5.
Dica 7 – Caso não seja validado o usuário então incrementar a SESSION[‘tentativas’], gravar na tabela “tab_log_tentativa” a tentativa e os dados informados. Quando a SESSION[‘tentativas’] chegar ao valor “5” então gravamos o valor bloqueado = “SIM” na tabela “tab_log_tentativa”, a próxima tentativa será bloqueada conforme explicado na Dica 4.
Observação sobre a Dica 7: Alguns tutoriais na WEB ensinam a bloquear tentativas usando somente SESSION, mas dessa forma se o usuário abrir outro navegador no mesmo computador ele vai conseguir tentar novamente, usando banco de dados em qualquer navegador do mesmo computador estará bloqueado. O Bloqueio só ocorre quando o usuário ou invasor errar 5 vezes seguidas email ou senha, senão iriamos bloquear usuários o dia inteiro.
Outros tutoriais ensinam a bloquear por “email” mas quem garante que o invasor vai tentar sempre com o mesmo email, por isso o IP acaba sendo uma boa alternativa.
Após todas essas validações é só verificar se a SESSION[‘logado’] possui o valor “SIM” e retornar o código “1” para requisição AJAX, senão retorna o código “0” e a mensagem informando das tentativas restantes.
login.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
<?php session_start(); // Constante com a quantidade de tentativas aceitas define('TENTATIVAS_ACEITAS', 5); // Constante com a quantidade minutos para bloqueio define('MINUTOS_BLOQUEIO', 30); // Require da classe de conexão require 'conexao.php'; // Dica 1 - Verifica se a origem da requisição é do mesmo domínio da aplicação if (isset($_SERVER['HTTP_REFERER']) && $_SERVER['HTTP_REFERER'] != "http://localhost/login/index.php"): $retorno = array('codigo' => , 'mensagem' => 'Origem da requisição não autorizada!'); echo json_encode($retorno); exit(); endif; // Instancia Conexão PDO $conexao = Conexao::getInstance(); // Recebe os dados do formulário $email = (isset($_POST['email'])) ? $_POST['email'] : '' ; $senha = (isset($_POST['senha'])) ? $_POST['senha'] : '' ; // Dica 2 - Validações de preenchimento e-mail e senha se foi preenchido o e-mail if (empty($email)): $retorno = array('codigo' => , 'mensagem' => 'Preencha seu e-mail!'); echo json_encode($retorno); exit(); endif; if (empty($senha)): $retorno = array('codigo' => , 'mensagem' => 'Preencha sua senha!'); echo json_encode($retorno); exit(); endif; // Dica 3 - Verifica se o formato do e-mail é válido if (!filter_var($email, FILTER_VALIDATE_EMAIL)): $retorno = array('codigo' => , 'mensagem' => 'Formato de e-mail inválido!'); echo json_encode($retorno); exit(); endif; // Dica 4 - Verifica se o usuário já excedeu a quantidade de tentativas erradas do dia $sql = "SELECT count(*) AS tentativas, MINUTE(TIMEDIFF(NOW(), MAX(data_hora))) AS minutos "; $sql .= "FROM tab_log_tentativa WHERE ip = ? and DATE_FORMAT(data_hora,'%Y-%m-%d') = ? AND bloqueado = ?"; $stm = $conexao->prepare($sql); $stm->bindValue(1, $_SERVER['SERVER_ADDR']); $stm->bindValue(2, date('Y-m-d')); $stm->bindValue(3, 'SIM'); $stm->execute(); $retorno = $stm->fetch(PDO::FETCH_OBJ); if (!empty($retorno->tentativas) && intval($retorno->minutos) <= MINUTOS_BLOQUEIO): $_SESSION['tentativas'] = ; $retorno = array('codigo' => , 'mensagem' => 'Você excedeu o limite de '.TENTATIVAS_ACEITAS.' tentativas, login bloqueado por '.MINUTOS_BLOQUEIO.' minutos!'); echo json_encode($retorno); exit(); endif; // Dica 5 - Válida os dados do usuário com o banco de dados $sql = 'SELECT id, nome, senha, email FROM tab_usuario WHERE email = ? AND status = ? LIMIT 1'; $stm = $conexao->prepare($sql); $stm->bindValue(1, $email); $stm->bindValue(2, 'A'); $stm->execute(); $retorno = $stm->fetch(PDO::FETCH_OBJ); // Dica 6 - Válida a senha utlizando a API Password Hash if(!empty($retorno) && password_verify($senha, $retorno->senha)): $_SESSION['id'] = $retorno->id; $_SESSION['nome'] = $retorno->nome; $_SESSION['email'] = $retorno->email; $_SESSION['tentativas'] = ; $_SESSION['logado'] = 'SIM'; else: $_SESSION['logado'] = 'NAO'; $_SESSION['tentativas'] = (isset($_SESSION['tentativas'])) ? $_SESSION['tentativas'] += 1 : 1; $bloqueado = ($_SESSION['tentativas'] == TENTATIVAS_ACEITAS) ? 'SIM' : 'NAO'; // Dica 7 - Grava a tentativa independente de falha ou não $sql = 'INSERT INTO tab_log_tentativa (ip, email, senha, origem, bloqueado) VALUES (?, ?, ?, ?, ?)'; $stm = $conexao->prepare($sql); $stm->bindValue(1, $_SERVER['SERVER_ADDR']); $stm->bindValue(2, $email); $stm->bindValue(3, $senha); $stm->bindValue(4, $_SERVER['HTTP_REFERER']); $stm->bindValue(5, $bloqueado); $stm->execute(); endif; // Se logado envia código 1, senão retorna mensagem de erro para o login if ($_SESSION['logado'] == 'SIM'): $retorno = array('codigo' => 1, 'mensagem' => 'Logado com sucesso!'); echo json_encode($retorno); exit(); else: if ($_SESSION['tentativas'] == TENTATIVAS_ACEITAS): $retorno = array('codigo' => , 'mensagem' => 'Você excedeu o limite de '.TENTATIVAS_ACEITAS.' tentativas, login bloqueado por '.MINUTOS_BLOQUEIO.' minutos!'); echo json_encode($retorno); exit(); else: $retorno = array('codigo' => '0', 'mensagem' => 'Usuário não autorizado, você tem mais '. (TENTATIVAS_ACEITAS - $_SESSION['tentativas']) .' tentativa(s) antes do bloqueio!'); echo json_encode($retorno); exit(); endif; endif; |
Página Principal
Após logado com sucesso o usuário é redirecionado para “home.php“, nesse exemplo montei uma home bem simples contendo apenas o nome do usuário logado e um link “Sair” direcionando para um script onde será destruído todas as SESSIONs.
No topo da home chamo o script “verifica_sessao.php” que tem como objetivo verificar se o usuário está logado, isso evita que usuários tentem acessar a url da home sem estar logado, o ideal é usar esse script no topo de todas as páginas da aplicação.
home.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<?php include "verifica_sessao.php"; ?> <!DOCTYPE html> <html> <head> <title>Menu Principal</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"> <style type="text/css"> .margin-top-md{ margin-top: 25px; } </style> </head> <body> <div class="container"> <div id="loginbox" class="mainbox col-md-10 col-md-offset-1 col-sm-8 col-sm-offset-2 margin-top-md"> <div class="panel panel-primary" > <div class="panel-heading"> <div class="panel-title">Menu - Olá (<?= $_SESSION['nome'] ?>)</div> </div> <div class="col-sm-12 controls margin-top-md"> <a href="logout.php" class="btn-lg btn-danger">Sair </a> </div> </div> </div> </div> </div> </body> </html> |
Script PHP para Verificar SESSION
No topo das páginas é verificado se existe um usuário logado chamando esse script, ele é bem simples mas pode receber mais regras conforme a necessidade do leitor.
Apenas verifico se existe a “SESSION[‘logado’]” e se ela possui o valor “SIM”, senão então redireciono o usuário para a página de login “index.php”.
verifica_sessao.php
1 2 3 4 5 6 |
<?php session_start(); if(!isset($_SESSION['logado'])): header("Location: index.php"); endif; |
Script PHP para Logout
Esse script é executado quando o usuário clica no link “Sair” da página home, é necessário poucas linhas para destruir as SESSIONs e redirecionar para a página de login “index.php”.
logout.php
1 2 3 4 5 |
<?php session_start(); session_destroy(); header("Location: index.php"); |
Resultado Final
Vou inserir um usuário e gerar o hash usando a nova API:
1 2 3 4 5 6 7 8 9 10 11 12 |
<?php require "conexao.php"; $nome = 'William'; $email = 'wllfl@ig.com.br'; $senha = password_hash('devwilliam', PASSWORD_DEFAULT); $conexao = conexao::getInstance(); $sql = "INSERT INTO tab_usuario(nome, email, senha, status)VALUES('{$nome}', '{$email}', '{$senha}', 'Ativo')"; $stm = $conexao->prepare($sql); $stm->execute(); |
Layout do formulário:
Validando preenchimento:
Validando formato do e-mail:
Digitando senha ou usuário errado na primeira tentativa:
Após errar 5 vezes seguidas usuário ou senha, aviso do bloqueio:
Se logado com sucesso acessa a home:
Download do exemplo Exemplo Formulário de Login (13668 downloads) .
Bom pessoal, nesse post demonstrei o caminho para se desenvolver um sistema de login com AJAX utilizando diversos níveis de validações para dificultar invasões, mas como já citei no início não existe sistema 100% seguro.
Essas validações podem até falhar, mas o invasor terá que suar um pouco rsrs …
Esse script PHP pode ser melhorado conforme a necessidade do leitor, inclusive adicionando algumas validações específicas de cada aplicação. Para o leitor que não optar em usar login com AJAX, poderá trabalhar sem problemas com as submissões de formulário convencionais, basta poucas adaptações.
Até a próxima …