symfony advent calendar dia 6: segurança e validação de fomulários
Anteriormente no symfony
Durante o quinto dia, você se habituou a manipular templates e ações; formulários e paginadores não são mais um segredo para você. Mas após criar o formulário de login, você provavelmente espera que nós lhe mostremos como restringir o acesso de usuários não autorizados a algum conjunto específico de funcionalidades. Isso é o que nós iremos fazer hoje, junto com alguma validação de formulário. Como nós iremos extender a aplicação com classes customizadas, assim, você deve estar familirizado com os conceitos expostos no capítulo de extensão customizada do livro symfony.
Validação do formulário de login
Arquivo de validação
O formulário de login possuia um campo login e um campo senha. Mas o que acontecerá se um usuário submeter dados incorretos? Para ser capaz de lidar com essa possibilidade, crie um arquivo login.yml no diretório /frontend/modules/user/validate (login é o nome da ação a ser validada). Adicione o seguinte conteúdo:
methods:
post: [login, senha]
names:
login:
required: true
required_msg: seu login é indispensável
validators: loginValidator
password:
required: true
required_msg: sua senha é indispensável
loginValidator:
class: sfStringValidator
param:
min: 5
min_error: o login deve conter no mínimo 5 caracteres
Primeiro, abaixo do cabeçalho methods, a lista de campos a ser validada é definida para os métodos do formulário (nós definimos aqui apenas o método POST porque o GET é usado para exibir o formulário de login e não precisa de validação). Então, sob o cabeçalho names, são listados os requerimentos de cada um dos campos a serem checados, junto com a mensagem de erro correspondente. Eventualmente, como o campo 'login' foi declarado para ter um conjunto específico de regras de validação, eles são detalhados sob o cabeçalho correspondente. Neste exemplo, o sfStringValidator é um validador pré-definido do symfony que verifica o formato de uma string (os validadores pré-padrões do symfony são cobertos no capítulo como validar um formulário do livro symfony).
Tratando erros
Então o que deveria acontecer se um usuário entrar com dados incorretos? As condições definidas no arquivo login.yml não serão atendidas, e o controlador do symfony irá passar a requisição para o método handleErrorLogin() da classe userActions - ao invés do método executeLogin(), como definido no argumento do form_tag. Se esse método não existir, o comportamento padrão é exibir o template loginError.php. Isso ocorre porque o método handleError() por padrão retorna:
[php]
public function handleError()
{
return sfView::ERROR;
}
Isso é um template inteiramente novo para escrever. Mas nós iremos exibir o formulário de login novamente, com mensagens de erro próximas aos campos com problemas. Então, vamos modificar o comportamento do erro de login para exibir, neste caso, o template loginSucess.php:
[php]
public function handleErrorLogin()
{
return sfView::SUCCESS;
}
Nota: As convenções de nomenclatura que relacionam o nome da ação, seu valor
returne o arquivo de template estão descricas no capítulo visão do livro symfony.
Helpers de template de erros
Com o template loginSuccess.php sendo chamado novamente, é hora de exibir os erros. Nós vamos usar o helper form_error() do grupo de helpers Validation para este fim. Mude as duas divs form-row do template para:
[php]
<?php use_helper('Validation') ?>
<div class="form-row">
<?php echo form_error('login') ?>
<label for="login">login:</label>
<?php echo input_tag('login', $sf_params->get('login')) ?>
</div>
<div class="form-row">
<?php echo form_error('password') ?>
<label for="password">password:</label>
<?php echo input_password_tag('password') ?>
</div>
O helper form_error() irá exibir a mensagem de erro definida no login.yml se um erro for declarado no campo passado como parametro.
É hora de testar a validação do formulário tentando inserir um login com menos de 5 caracteres, ou omitindo um dos dois campos. A mensagem de erro é magicamente exibida sobre os campos correspondentes:
A senha agora é obrigatória, mas não existem senhas no banco de dados! Isso não importa, logo que você entrar com uma senha, o login será realizado com sucesso. Esse não é um processo muito seguro, não é?
Estilo nos erros
Se você testou o formulário e obteve uma mensagem de erro, você provavelmnte notou que os seus erros não estão com o mesmo estilo dos erros na imagem acima. Isso ocorre porque nós definimos um estilo para a classe .form_error (no arquivo web/main.css), que é a classe padrão dos erros de formulários gerados pelo helper form_error():
[css]
.form_error
{
padding-left: 85px;
color: #d8732f;
}
Autenticando um usuário
Validador customizado
Você se lembra da checagem feita ontem, de um login inserido na ação login? Bem, isso parece com uma validação de formulário. Esse código deveria ser removido da ação e incluído em um validador customizado. Você acha que isso é complicado? Na verdade não é. Edite o arquivo de validação login.yml como a seguir:
...
names:
login:
required: true
required_msg: Seu login é indispensável
validators: [loginValidator, userValidator]
...
userValidator:
class: myLoginValidator
param:
password: senha
login_error: essa conta não existe ou você inseriu uma senha incorreta
Nós apenas adicionamos um novo validador para o campo login, da classe myLoginValidator. Este validador não existe ainda, mas nós sabemos que ele necessitará do campo senha para autenticar completamente o usuário, então ele é passado como parâmetro com o nome senha.
Armazenamento de senha
Mas espere um pouco. No nosso modelo de dados, bem como no nossos dados de teste, não existe uma senha definida. É hora de definir uma. Mas você sabe que armazenar uma senha em texto literal, no banco de dados, é uma má ideia por razões de segurança. Então nós vamos armazenar um hash sha1 da senha assim como a chave randômica usada para criar este hash. Se você não está habituado com este 'salt' process, verifique as práticas para quebra de senha.
Então abra o schema.xml e adicione as seguintes colunas à tabela Usuario:
[xml]
<column name="email" type="varchar" size="100" />
<column name="sha1_senha" type="varchar" size="40" />
<column name="salt" type="varchar" size="32" />
Reconstrua o modelo do Propel com o comando symfony propel-build-model. Você deve também adicionar as duas colunas ao banco de dados, manualmente ou usando o schema.sql gerado após um symfony propel-build-sql. Agora abra o askeet/lib/model/Usuario.php e adicione este método setPassword():
[php]
public function setPassword($password)
{
$salt = md5(rand(100000, 999999).$this->getlogin().$this->getEmail());
$this->setSalt($salt);
$this->setSha1Password(sha1($salt.$password));
}
Essa função simula um armazenamento direto de senha, mas, ao invés, ela armazena a chave randômica salt (um hash em formato string de 32 caracteres) e o hash da senha (uma string com 40 caracteres).
Adicionando uma senha aos dados de teste
Lembra-se do arquivo com dados de teste do dia três? É hora de adicionar uma senha e um email para o usuário de teste. Abra e altere o arquivo askeet/data/fixtures/test_data.yml conforme o seguinte:
User:
...
fabien:
login: fabpot
first_name: Fabien
last_name: Potencier
senha: symfony
email: fp@example.com
francois:
login: francoisz
first_name: François
last_name: Zaninotto
senha: adventcal
email: fz@example.com
Como o método setPassword() foi definido na classe User, o objeto sfPropelData vai preencher corretamente as novas colunas sha1_password e o seu salt definidos no schema quando nós chamarmos:
$ php batch/load_data.php
Nota: Note que o objeto
sfPropelDataé capaz de lidar com métodos que não são associados a colunas 'reais' no banco de dados (e agora nós ultrapassamos o seu tradicional SQL dump!).Se você se perguntou como isso é possível, dê uma olhada no Capítulo de preenchimento de banco de dados do livro symfony.
Nota: Não há necessidade de definir uma senha para o usuário 'Covarde Anônimo' pois iremos proibí-lo de logar. E nós agradeceríamos se você não tentar as duas senhas exibidas aqui nas nossas contas bancárias, pois elas são confidenciais!
Validador Customizado
Agora chegou o momento de escrevermos o myLoginValidator. Você pode criar ele em qualquer diretório dentro de lib/ que seja acessível ao módulo (isso é, no askeet/lib/, ou no askeet/apps/frontend/lib/, ou no askeet/apps/frontend/modules/user/lib/). Por enquanto, vamos considerar que este validador abrange toda a aplicação, então o arquivo myLoginValidator.class.php será criado no diretório askeet/apps/frontend/lib/:
[php]
<?php
class myLoginValidator extends sfValidator
{
public function initialize($context, $parameters = null)
{
// inicializa parent
parent::initialize($context);
// define valores padrao
$this->setParameter('login_error', 'Valor inválido');
$this->getParameterHolder()->add($parameters);
return true;
}
public function execute(&$value, &$error)
{
$password_param = $this->getParameter('senha');
$password = $this->getContext()->getRequest()->getParameter($password_param);
$login = $value;
// anonymous is not a real user
if ($login == 'anonimo')
{
$error = $this->getParameter('login_error');
return false;
}
$c = new Criteria();
$c->add(UserPeer::login, $login);
$user = UserPeer::doSelectOne($c);
// o login existe?
if ($user)
{
// a senha esta OK?
if (sha1($user->getSalt().$password) == $user->getSha1Password())
{
$this->getContext()->getUser()->setAuthenticated(true);
$this->getContext()->getUser()->addCredential('subscriber');
$this->getContext()->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
$this->getContext()->getUser()->setAttribute('login', $user->getlogin(), 'subscriber');
return true;
}
}
$error = $this->getParameter('login_error');
return false;
}
}
Quando o validador for requerido - após a submissão do formulário login - o método initialize() é chamado primeiro. Ele inicia o valor padrão da mensagem login_error ('Valor inválido') e junta os parâmetros (os que estão sob o cabeçalho param: no arquivo login.yml) no objeto portador dos parâmetros.
Quando o método execute() é... executado. O $password_param é o nome provido no login.yml sob o cabeçalho password. Ele é usado como um nome de campo para receber um valor do parâmetros de requisição. Então $password contem a senha inserida pelo usuário. $value pega o valor do campo atual - e a classe myLoginValidator é chamada para o campo login. Então $login contém o login inserido pelo usuário. Finalmente! Agora o validador possui toda a informação necessária para realmente validar o usuário.
O código a seguir foi retirado da ação login. Mas, em adição, o teste da validade da senha (anteriormente sempre true) foi implementado: Um hash da senha inserida pelo usuário (usando o salt armazenado no banco de dados) é comparado com o hash da senha do usuário.
Se o login e a senha estiverem corretos, o validador retorna true e a ação alvo do formulário (executeLogin()) será executado. Se não, ele retorna false e é o handleErrorLogin() que será executado.
Remova o código da ação
Agora que todo o código da validação está localizado dentro do validador, nós precisamos remover ele da ação login. Na verdade, quando a ação é chamada com o método POST, isso significa que o validador validou a requisição, então o usuário está correto. Isso significa que a única coisa que a ação precisa fazer neste caso é redirecionar para a página referer:
[php]
public function executeLogin()
{
if ($this->getRequest()->getMethod() != sfRequest::POST)
{
// display the form
$this->getRequest()->getParameterHolder()->set('referer', $this->getRequest()->getReferer());
return sfView::SUCCESS;
}
else
{
// handle the form submission
// redirect to last page
return $this->redirect($this->getRequestParameter('referer', '@homepage'));
}
}
Teste as modificações tentando logar com um dos usuários de teste (após limpar o cache, já que nós criamos uma nova classe de validador que precisa ser carregada automaticamente).
Acesso restrito
Se você quer restringir o acesso à uma ação, você precisa apenas adicionar um security.yml no diretório config/ do módulo, como o seguinte (não o faça ainda):
all:
is_secure: on
credentials: subscriber
As ações deste módulo serão apenas adicionados se o usuário for autenticado, e possuir uma credencial subscriber.
No askeet, login será requerido para postar uma nova questão, para declarar interesse sobre uma questão, e para dar nota à um comentário. Todas as outras ações serão liberadas para usuários não logados.
Então para restringir o acesso da ação `question/add(que ainda será escrita), adicione o arquivosecurity.yml no diretório askeet/apps/frontend/modules/question/config/:
add:
is_secure: on
credentials: subscriber
all:
is_secure: off
Que tal um pouco de refactoring?
O dia está quase terminado, mas nós gostaríamos de jogar nosso jogo favorito um pouco mais: O jogo mova-o-código-para-um-lugar-diferente.
As quatro linhas de código que eram executadas quando a senha é validada concedem acesso ao usuário e salvam seu id para requisições futuras. Você pode ver ele como um método para a classe myUser (a classe de sessão, não a classe User que corresponde à coluna User). Isso é fácil de fazer. Adicione o seguinte método para a classe askeet/apps/frontend/lib/myUser.php:
[php]
public function signIn($user)
{
$this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
$this->setAuthenticated(true);
$this->addCredential('subscriber');
$this->setAttribute('login', $user->getlogin(), 'subscriber');
}
public function signOut()
{
$this->getAttributeHolder()->removeNamespace('subscriber');
$this->setAuthenticated(false);
$this->clearCredentials();
}
Agora, mude as quatro linhas começando pelo $this->getContext()->getUser() na classe myLoginValidator com:
[php]
$this->getContext()->getUser()->signIn($user);
E também altere a ação user/logout (você tinha se esquecido dessa?) para:
[php]
public function executeLogout()
{
$this->getUser()->signOut();
$this->redirect('@homepage');
}
Os atributos da sessão subscriber_id e login também podem ser abstraídos através de um método getter. Ainda na classe myUser, adicione as três métodos seguintes:
[php]
public function getSubscriberId()
{
return $this->getAttribute('subscriber_id', '', 'subscriber');
}
public function getSubscriber()
{
return UserPeer::retrieveByPk($this->getSubscriberId());
}
public function getlogin()
{
return $this->getAttribute('login', '', 'subscriber');
}
Você pode usar um dos novos métodos no layout.php: mude a linha
[php]
<li><?php echo link_to($sf_user->getAttribute('login', '', 'subscriber').' profile', 'user/profile') ?></li>
para
[php]
<li><?php echo link_to($sf_user->getlogin().' profile', 'user/profile') ?></li>
Não esqueça de testar as modificações. O mesmo processo de login realizado antes ainda deve funcionar - mas agora com um código melhor.
Vejo você amanhã
Amanhã, será hora de trabalhar um pouco na configuração da view, para customizar CSS, compenentes consistentes, e para tomar conta dos cabeçalhos da página.
Não esqueça de que você ainda pode fazer o download do código completo de hoje do repositório SVN do askeet, marcado release_day_6. Se você quiser perguntar ou responder perguntas sobre o askeet, sinta-se a vontade para visitar o fórum do askeet. Não esqueça que o programa do dia 21 ainda depende de você.