Commit 56c30b69 authored by Fabrice Gangler's avatar Fabrice Gangler 🎨
Browse files

FIX(anti-spam): reduce user creation by bots

parent 9dcd1254
Pipeline #13448 passed with stage
in 4 minutes and 12 seconds
......@@ -464,21 +464,153 @@ class UsersController extends AppController
}
/**
* Tooling for "add" method: create anti-spam tokens
* - create anti-spam tokens (get, post, form)
* - save tokens in user session
* - return token for HTTP GET method
*
* @return string token for HTTP GET method
*/
private function addUserCreateTokens()
{
$prefixToken = bin2hex(openssl_random_pseudo_bytes(12));
$getSuffixToken = bin2hex(openssl_random_pseudo_bytes(12));
$postSuffixToken = bin2hex(openssl_random_pseudo_bytes(12));
$formSuffixToken = bin2hex(openssl_random_pseudo_bytes(12));
$dataToken = $this->request->session()->read('CreateUserAntiSpam');
if (is_null($dataToken)) {
$dataToken = [];
}
$dataToken["$prefixToken"] = [
"date" => time(),
"getToken" => "$prefixToken-$getSuffixToken",
"postToken" => "$prefixToken-$postSuffixToken",
"formToken" => "$prefixToken-$formSuffixToken",
];
$this->request->session()->write('CreateUserAntiSpam', $dataToken);
return "$prefixToken-$getSuffixToken";
}
/**
* Tooling for "add" method: get current anti-spam token based on current HTTP method
* @return string
*/
private function addUserGetCurrentToken()
{
$method = strtolower($this->request->method());
if ($method === 'get') {
return strip_tags($this->request->query('t1'));
} elseif ($method === 'post') {
return strip_tags($this->request->query('t2'));
}
return ''; // wrong HTTP method
}
/**
* Tooling for "add" method: check if current anti-spam token in URL is valid
* @return bool
*/
private function addUserIsValidTokenInUrl()
{
$token = $this->addUserGetCurrentToken();
if (empty($token)) {
return false; // wrong HTTP method or token parameter in the URL does not exist
} else {
$method = strtolower($this->request->method());
$prefixToken = $this->addUserGetPrefixToken($token);
$availableTokens = $this->request->session()->read('CreateUserAntiSpam');
if (!isset($availableTokens[$prefixToken])) {
return false;
} elseif ($availableTokens[$prefixToken][$method .'Token'] !== $token) {
return false;
}
}
return true;
}
/**
* Tooling for "add" method: get anti-spam prefix token
*
* @param string $token
* @return mixed|string
*/
private function addUserGetPrefixToken($token)
{
$tokenData = explode('-', $token);
return $tokenData[0];
}
/**
* Tooling for "add" method: get current token by type ('get', 'post' or 'form')
*
* @param string $type 'get', 'post' or 'form'
* @return string
*/
private function addUserGetTokenByType(string $type)
{
$prefixToken = $this->addUserGetPrefixToken($this->addUserGetCurrentToken());
$availableTokens = $this->request->session()->read('CreateUserAntiSpam');
$token = '';
if (isset($availableTokens["$prefixToken"][$type.'Token'])) {
$token = $availableTokens["$prefixToken"][$type.'Token'];
}
return $token;
}
/**
* Tooling for "add" method: check if form is sent too quicky
* @return bool
*/
private function addUserIsFormSentTooQuickly()
{
$prefixToken = $this->addUserGetPrefixToken($this->addUserGetCurrentToken());
$availableTokens = $this->request->session()->read('CreateUserAntiSpam');
if (isset($availableTokens["$prefixToken"])) {
$tokenTimestamp = $availableTokens["$prefixToken"]['date'];
$diffTimestamp = time() - $tokenTimestamp;
if ($diffTimestamp >= 4) { // @@@TODO magic number !!!
return false;
}
}
return true;
}
/**
* Add method
*
* @return void Redirects on successful add, renders view otherwise.
*/
public function add()
{
$message = "";
$lang = $this->selectedLanguage;
if (!empty($this->request->data)) {
$user = $this->Users->newEntity($this->request->data);
} else {
$user = $this->Users->newEntity();
}
$message = "";
// Force token-based URL (HTTP GET and POST methods), expect for json API
if (!$this->request->is('json') && $this->addUserIsValidTokenInUrl() === false) {
return $this->redirect("/$lang/users/add?t1=". $this->addUserCreateTokens());
}
$dataToken = $this->request->session()->read('CreateUserAntiSpam');
if ($this->request->is('post')) {
// Check that it's not a robot (expected token and speed submit), expect for json API
if (!$this->request->is('json')) {
$expectedFormToken = $this->addUserGetTokenByType('form');
$formToken = $this->request->data("formToken");
if ($expectedFormToken !== $formToken | $this->addUserIsFormSentTooQuickly()) {
return $this->redirect("/$lang/users/add?t1=". $this->addUserCreateTokens());
}
}
// do not allow to upload an avatar when creating an account
$this->request->data['photo'] = null;
......@@ -506,6 +638,7 @@ class UsersController extends AppController
if ($this->Users->save($user)) {
$message = "Success";
$this->Flash->success(__d("Forms", "Your are registred on the Comptoir du Libre, welcome !"));
$this->request->session()->write('CreateUserAntiSpam', []); // Unset tokens
if (!$this->request->is('json')) {
$currentUser = $this->Auth->identify();
$currentUser["user_type"] = $this->Users
......@@ -514,7 +647,6 @@ class UsersController extends AppController
$this->Auth->setUser($currentUser);
// REDIRECTS TO ---> /<lang>/users/<idUser>
$lang = $this->selectedLanguage;
return $this->redirect("/$lang/users/" . $user->get('id'));
// REDIRECTS TO ---> /api/v1/users/view/<idUser>
// return $this->redirect(['action' => 'view', $user->get('id')]);
......@@ -527,7 +659,16 @@ class UsersController extends AppController
}
$this->ValidationRules->config('tableRegistry', "Users");
$rules = $this->ValidationRules->get();
$tokenForForm = $this->addUserGetTokenByType('form');
$formUrl = "/$lang/users/add?t2=" . $this->addUserGetTokenByType('post');
$userTypes = $this->Users->UserTypes->find('list', ['limit' => 200]);
if (!$this->request->is('json')) {
$this->set(compact(['formUrl']));
$this->set(compact(['tokenForForm']));
}
$this->set(compact('user', 'userTypes', 'rules', 'message'));
$this->set('_serialize', ['user', 'userTypes', 'rules', 'message', 'errors']);
......
......@@ -14,7 +14,15 @@ $this->assign('title', __d("Forms", "Create an account {0}", $myMessage));
<div class="row">
<div class = "col-xs-offset-3 col-xs-6">
<?= $this->Form->create($user, ['type' => 'file', "enctype" => "multipart/form-data", 'id' => "createAccountForm"]) ?>
<?= $this->Form->create($user, ['url' => "$formUrl", 'type' => 'file', "enctype" => "multipart/form-data", 'id' => "createAccountForm"]) ?>
<?= $this->Form->input('formToken',
[
'value' => $tokenForForm,
'empty' => false,
"type"=>"hidden",
"required"=>"required",
"escape"=>false,
]);?>
<fieldset>
<legend><?= "<h1>" . __d("Forms",'Create an account') . "</h1>" ?></legend>
<div class = "form-group">
......
......@@ -122,11 +122,15 @@ class AcceptanceTester extends Actor
$I = $this;
$I->amOnPage('/');
$I->click('//nav/ul[2]/li[2]/a');
$I->seeInCurrentUrl('/users/add');
$I->seeInCurrentUrl('/users/add?t1=');
$random = $I->getCurrentRandom();
$randomUsername = "$username"."_$random";
$randomEmail = str_replace("@", "$random@", "$email");
// echo "\n-------------\n$randomUsername\n$randomEmail\n---------\n";
// Add delay to look like a real user and not a robot
sleep(4); // @@@TODO magic number !!!
$I->submitForm(
'#createAccountForm',
[
......
......@@ -175,6 +175,182 @@ class UsersControllerTest extends ApiIntegrationTestCase
];
}
/**
* Test add method like a end user (HTML form)
* - Check that account creation URL redirects to a URL with a "t1" parameter containing a token
* - Check that form contains the tokens for the form processing URL and the hidden field
* - Try to display form with a wrong token in URL
* - Try to display form without a token in URL
* - Try to send form with a wrong token in URL
* - Try to send form without a token in URL
* - Try to send form with a valid token in URL, but without a token input in form
* - Try to send form with a valid token in URL, but with a wrong token input in form
* - Try to send form with valid tokens in URL and form, but too quickly (as a bot)
* - Send form with valid tokens in URL and form, like a real user (not too quickly)
*
* URL: /en/users/add ---> redirect to /en/users/add?t1=<token>
* /fr/users/add ---> redirect to /en/users/add?t1=<token>
* /fr/users/add?t1=<token> GET method
* /fr/users/add?t2=<token> POST method
*
* @group public
* @group user
* @group createUser
*
* @return void
*/
public function testAddHtmlForm()
{
// French user:
$testeddUrl = '/fr/users/add';
$expectedUrlPrefix = '/fr/users/add?t1=';
$title = "<title>Créer un compte";
// Form data
$emailFixture1 = 'bob_'. mt_rand().'@example.com';
$userFixture1 = [
'username' => 'Bob',
'url' => 'url',
'user_type_id' => 1,
'description' => 'A description',
'role' => 'user',
'password' => 'passwd',
'confirm_password' => 'passwd',
'email' => $emailFixture1,
];
// Check that the following users do not exist in the database before creating it.
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count());
// Anonymous user
$this->setAnonymousUserSession();
$this->get('/');
$this->assertArrayNotHasKey('CreateUserAntiSpam', $_SESSION);
// Check that the account creation URL
// redirects to a URL with a "t1" parameter containing a token.
$this->get($testeddUrl);
$this->assertResponseCode(302);
$this->assertRedirectContains($expectedUrlPrefix);
$this->assertArrayHasKey('CreateUserAntiSpam', $_SESSION);
$this->assertEquals(1, \count($_SESSION['CreateUserAntiSpam']));
$tokenPrefix1 = array_key_first($_SESSION['CreateUserAntiSpam']);
$tokenGetMethod1 = $_SESSION['CreateUserAntiSpam'][$tokenPrefix1]['getToken'];
$tokenPostMethod1 = $_SESSION['CreateUserAntiSpam'][$tokenPrefix1]['postToken'];
$tokenForm1 = $_SESSION['CreateUserAntiSpam'][$tokenPrefix1]['formToken'];
$expectedUrl1 = $expectedUrlPrefix . $tokenGetMethod1;
$headers = $this->_response->header();
$this->assertEquals($expectedUrl1, $headers['Location']);
// Save session data for future requests
$backupSession = $_SESSION;
// Add form token to fixtures
$userFixture1['formToken'] = $tokenForm1;
// Try to display form with a wrong token in the URL
// --> redirect to empty form (with new generated token)
$this->session($backupSession); // Use backuped SESSION
$this->get($expectedUrlPrefix ."bad45-token45");
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
// Try to display form without a token in the URL
// --> redirect to empty form (with new generated token)
$this->session($backupSession); // Use backuped SESSION
$this->get($testeddUrl);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
// Check that form contains the tokens
// for the form processing URL and the hidden field
$this->session($backupSession); // Use backuped SESSION
$r = $this->checkUrlOk($expectedUrl1, ['html']);
$html = $r['html']['data'];
$this->assertContains('<html lang="fr">', $html);
$this->assertContains("$title", $html);
$this->assertContains("action=\"/fr/users/add?t2=$tokenPostMethod1\"", $html);
$this->assertContains("id=\"formtoken\" value=\"$tokenForm1\"", $html);
// Try to send form with a wrong token in the URL
// --> redirect to empty form (with new generated token)
// --> new user has not been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$this->post("/fr/users/add?t2=$tokenPostMethod1"."badTokenSuffix", $userFixture1);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
// Try to send form without a token in the URL
// --> redirect to empty form (with new generated token)
// --> new user has not been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$this->post("/fr/users/add", $userFixture1);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
// Try to send form with a valid token in the URL, but without a token input in form
// --> redirect to empty form (with new generated token)
// --> new user has not been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$userFixture1WithoutFormToken = $userFixture1;
unset($userFixture1WithoutFormToken['formToken']);
$this->post("/fr/users/add?t2=$tokenPostMethod1", $userFixture1WithoutFormToken);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
// Try to send form with a valid token in the URL, but with a wrong token input in form
// --> redirect to empty form (with new generated token)
// --> new user has not been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$userFixture1WithBadFormToken = $userFixture1;
$userFixture1WithBadFormToken['formToken'] = 'bad45-token45';
$this->post("/fr/users/add?t2=$tokenPostMethod1", $userFixture1WithBadFormToken);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
// Try to send form with a valid token in the URL, but too quickly (as a bot)
// --> redirect to empty form (with new generated token)
// --> new user has not been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$this->post("/fr/users/add?t2=$tokenPostMethod1", $userFixture1);
$this->commonCheckForAddHtmlForm($expectedUrlPrefix);
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
// Add delay to look like a real user and not a robot
sleep(4); // @@@TODO magic number !!!
// Send form with a valid token in the URL, like a real user (not too quickly)
// --> new user has been created
$this->session($backupSession); // Use backuped SESSION
$this->assertEquals(0, $this->Users->find()->where(['email' => $emailFixture1])->count()); // not exist in DB
$this->post("/fr/users/add?t2=$tokenPostMethod1", $userFixture1);
$this->assertResponseCode(302);
$headers = $this->_response->header();
$this->assertEquals("/fr/users/11", $headers['Location']);
$this->assertEquals(1, $this->Users->find()->where(['email' => $emailFixture1])->count()); // exist in DB
}
/**
* Common checks for add method like a end user (HTML form)
* -> redirect to empty form (with new generated token)
*
* @param $expectedUrlPrefix
*/
private function commonCheckForAddHtmlForm($expectedUrlPrefix)
{
$this->assertResponseCode(302);
$this->assertRedirectContains($expectedUrlPrefix);
$this->assertArrayHasKey('CreateUserAntiSpam', $_SESSION);
$this->assertEquals(2, \count($_SESSION['CreateUserAntiSpam']));
$tokenPrefix = array_key_last($_SESSION['CreateUserAntiSpam']);
$tokenGetMethod = $_SESSION['CreateUserAntiSpam'][$tokenPrefix]['getToken'];
$expectedUrl = $expectedUrlPrefix . $tokenGetMethod;
$headers = $this->_response->header();
$this->assertEquals($expectedUrl, $headers['Location']);
}
/**
* Test add method
......
......@@ -10,6 +10,8 @@ Disallow:
# Blocking all web crawlers for specific content
User-agent: *
Disallow: /api/
Disallow: /fr/users/add
Disallow: /en/users/add
# Allowing all web crawlers access to all content
User-agent: *
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment