diff --git a/app/config/conf.app.yml b/app/config/conf.app.yml index d2f073e..01819d0 100644 --- a/app/config/conf.app.yml +++ b/app/config/conf.app.yml @@ -4,6 +4,7 @@ application: modelsDir : ../app/models/ migrationsDir : ../app/migrations/ viewsDir : ../app/views/ + templateDir : ../app/templates/ listenersDir : ../app/listeners/ libraryDir : ../app/library/ formsDir : ../app/forms/ diff --git a/app/config/conf.local.sample.yml b/app/config/conf.local.sample.yml index 8fa5619..8792856 100644 --- a/app/config/conf.local.sample.yml +++ b/app/config/conf.local.sample.yml @@ -11,6 +11,9 @@ database: dbname: httpcb charset: utf8 +#sendgrid + #key: value + # OAuth #oauth: #providers: diff --git a/app/config/services.php b/app/config/services.php index c8499ef..0b4cc2c 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -134,6 +134,11 @@ $di->setShared('router', function() { 'action' => 'settings', ))->setName('user-settings'); + $router->add('/act/{link}', array( + 'controller' => 'user', + 'action' => 'activationlink', + ))->setName('activation-link'); + return $router; }); @@ -176,6 +181,25 @@ $di->setShared('view', function () use ($di, $config) { return $view; }); +$di->set('template', function() use ($config) { + $view = new Phalcon\Mvc\View\Simple(); + $view->setViewsDir($config->application->templateDir); + $view->registerEngines([ + '.volt' => function ($view, $di) use ($config) { + $volt = new VoltEngine($view, $di); + + $volt->setOptions(array( + 'compiledPath' => $config->application->viewCacheDir, + 'compiledSeparator' => '_', + )); + + return $volt; + }, + '.phtml' => 'Phalcon\Mvc\View\Engine\Php' + ]); + return $view; +}); + $di->setShared('assets', function () { $manager = new AssetsManager(); @@ -222,6 +246,10 @@ $di->setShared('db', function () use ($di, $config) { return $db; }); +$di->setShared('sendgrid', function() use ($config) { + return new SendGrid($config->sendgrid->key); +}); + $di->setShared('eventsManager', function () { $activityLog = new ActivityLog(); diff --git a/app/controllers/UserController.php b/app/controllers/UserController.php index e9c5782..3d4aab1 100644 --- a/app/controllers/UserController.php +++ b/app/controllers/UserController.php @@ -4,7 +4,9 @@ namespace App\Controller; use App\Controller\ControllerBase, App\Form\UserSettings as UserSettingsForm, - App\Model\Data\ActivityLog; + App\Model\Data\ActivityLog, + App\Model\Data\PasswordLink, + SendGrid\Mail\Mail as SendGridMail; class UserController extends ControllerBase { @@ -28,9 +30,42 @@ class UserController extends ControllerBase $new_pw = $form->getValue('passwordNew'); if (strlen($new_pw) > 0) { + $hash = password_hash($new_pw, PASSWORD_BCRYPT); - $user->setPassword($hash); + + // User had a password before. just update. + if (strlen($user->getPassword()) > 0) { + $user->setPassword($hash); + } + // Else we create a password link and email. + else { + $link = new PasswordLink(); + $link->setUserId($user->getId()) + ->setPassword($hash) + ->save(); + + $tpl = $this->di->get('template'); + $body = $tpl->render('mail/password_activation', [ + 'link' => $link->getPublicId() + ]); + + $mail = new SendGridMail(); + $mail->setFrom('noreply@shufflingpixels.com'); + $mail->setSubject('Httpcb password activation'); + $mail->addTo($user->getEmail()); + $mail->addContent('text/html', $body); + + $sendgrid = $this->di->get('sendgrid'); + $sendgrid->send($mail); + + $msg = "For security reasons. Before a password can be created " + . "a email has been sent to {$user->getEmail()} with " + . "a activation link."; + + $this->flash->notice($msg); + } } + $user->save(); $form->initialize(); @@ -44,6 +79,35 @@ class UserController extends ControllerBase $this->view->form = $form; } + /** + * Activate a password. + * + * @param $id + */ + public function activationLinkAction($id) + { + $link = PasswordLink::findFirst(['public_id = ?0', 'bind' => [ $id ]]); + + if ($link) { + if ($link->isValid()) { + + // Save the password. + $link->getUser() + ->setPassword($link->getPassword()) + ->save(); + + $this->flash->success('Your password has been activated.'); + } else { + $this->flash->error('This link has expired or has already been used.'); + } + + // Make sure the link is deleted. + $link->delete(); + } else { + $this->flash->error('This does not seem to be an active link'); + } + } + public function activityAction($page = 1) { $user = $this->_getAuth()->getUser(); diff --git a/app/library/Mvc/Model/Behavior/RandomId.php b/app/library/Mvc/Model/Behavior/RandomId.php index e5c644a..93d3c2a 100644 --- a/app/library/Mvc/Model/Behavior/RandomId.php +++ b/app/library/Mvc/Model/Behavior/RandomId.php @@ -60,6 +60,12 @@ class RandomId extends Behavior implements BehaviorInterface $field = $this->_options['field']; $len = $this->_options['length']; + if (isset($this->_options['expression'])) { + $expr = 'AND ' . $this->_options['expression']; + } else { + $expr = ''; + } + if ($model->$field === null) { $random = new \Phalcon\Security\Random(); @@ -67,7 +73,7 @@ class RandomId extends Behavior implements BehaviorInterface $id = substr($random->base64Safe(), 0, $len); $count = $model->count(array( - "$field = ?0", + "$field = ?0 $expr", 'bind' => array($id) )); diff --git a/app/migrations/20180611202847_password_link.php b/app/migrations/20180611202847_password_link.php new file mode 100644 index 0000000..843dd11 --- /dev/null +++ b/app/migrations/20180611202847_password_link.php @@ -0,0 +1,18 @@ +table('password_link') + ->addColumn('public_id', 'string', ['length' => 12 ]) + ->addColumn('user_id', 'integer') + ->addForeignKey('user_id', 'user', ['id'], [ 'constraint' => 'FK_password_link_user' ]) + ->addColumn('date', 'datetime', [ 'default' => 'CURRENT_TIMESTAMP' ]) + ->addColumn('password', 'string', [ 'limit' => 255, 'null' => true ]) + ->save(); + } +} diff --git a/app/models/Data/PasswordLink.php b/app/models/Data/PasswordLink.php new file mode 100644 index 0000000..9f834c4 --- /dev/null +++ b/app/models/Data/PasswordLink.php @@ -0,0 +1,174 @@ +setSource('password_link'); + $this->useDynamicUpdate(true); + + // Relationships + $this->belongsTo('user_id', User::class, 'id', array('alias' => 'User')); + + // Behaviour + $this->addBehavior(new RandomIdBehavior(array( + 'field' => 'public_id', + 'length' => 12, + 'expression' => '(password IS NULL OR HOUR(TIMEDIFF(date, NOW())) = 0)' + ))); + + $this->addBehavior(new SoftDelete([ + 'field' => 'password', + 'value' => null + ])); + } + + /** + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * @param int $id + * @return PasswordLink + */ + public function setId(int $id) : PasswordLink + { + $this->id = $id; + return $this; + } + + /** + * @return string + */ + public function getPublicId() + { + return $this->public_id; + } + + /** + * @param string $public_id + * @return PasswordLink + */ + public function setPublicId(string $public_id) : PasswordLink + { + $this->public_id = $public_id; + return $this; + } + + /** + * @return int + */ + public function getUserId() + { + return $this->user_id; + } + + /** + * @param int $user_id + * @return PasswordLink + */ + public function setUserId(int $user_id) : PasswordLink + { + $this->user_id = $user_id; + return $this; + } + + /** + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * @param string $password + * @return PasswordLink + */ + public function setPassword(string $password) : PasswordLink + { + $this->password = $password; + return $this; + } + + /** + * Has the link been used? + * + * @return bool + */ + public function isUsed() : bool + { + return strlen($this->password) < 1; + } + + /** + * Is the link still valid? + * + * @return bool + */ + public function isValid() : bool + { + // Used links are not valid. + if ($this->isUsed()) { + return false; + } + + $date = new \DateTime($this->date); + $now = new \DateTime(); + + // Get the differerence in hours. + $diff = $now->format('U') - $date->format('U'); + $diff = ($diff / 60) / 60; + + // Only valid for an hour. + return $diff >= 0 && $diff <= 1; + } + + public function beforeCreate() + { + // Creating a new link automatically removes old ones. + $links = self::find(["user_id = ?0", 'bind' => [ $this->user_id ]]); + + foreach($links as $link) { + $link->delete(); + } + } +} diff --git a/app/templates/mail/password_activation.volt b/app/templates/mail/password_activation.volt new file mode 100644 index 0000000..3b9e380 --- /dev/null +++ b/app/templates/mail/password_activation.volt @@ -0,0 +1,10 @@ + +{% set link_url = request.getScheme() ~ '://' + ~ request.getHttpHost() + ~ url(['for': 'activation-link', 'link': link ]) %} + +

Httcb Password Activation

+ +

Please click here to activate the password.

+ +

Or copy and paste this link into the address field in your browser: {{ link_url }}

diff --git a/composer.json b/composer.json index 955c3ff..481a621 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "league/oauth2-github": "^2.0", "league/oauth2-google": "^2.2", "omines/oauth2-gitlab": "^3.1", - "localheinz/json-printer": "^2.0" + "localheinz/json-printer": "^2.0", + "sendgrid/sendgrid": "^7.0" } } diff --git a/composer.lock b/composer.lock index 8031bbd..18c64ec 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "5b02615c35e5c720f8fc96266a7f87a3", - "content-hash": "1e872347d68f7521be008e5a53d4b5e4", + "hash": "cb468603a22f2d265e74e0e1cb692ca1", + "content-hash": "f519b8b85514afd2d01426a06e3bea02", "packages": [ { "name": "guzzlehttp/guzzle", @@ -687,6 +687,113 @@ ], "time": "2017-12-23 06:48:51" }, + { + "name": "sendgrid/php-http-client", + "version": "3.9.6", + "source": { + "type": "git", + "url": "https://github.com/sendgrid/php-http-client.git", + "reference": "e9a04d949ee2d19938ab83dc100933a3b41a8ec7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sendgrid/php-http-client/zipball/e9a04d949ee2d19938ab83dc100933a3b41a8ec7", + "reference": "e9a04d949ee2d19938ab83dc100933a3b41a8ec7", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "~4.4", + "squizlabs/php_codesniffer": "~2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SendGrid\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Bernier", + "email": "dx@sendgrid.com" + }, + { + "name": "Elmer Thomas", + "email": "elmer@thinkingserious.com" + } + ], + "description": "HTTP REST client, simplified for PHP", + "homepage": "http://github.com/sendgrid/php-http-client", + "keywords": [ + "api", + "fluent", + "http", + "rest", + "sendgrid" + ], + "time": "2018-04-10 18:06:08" + }, + { + "name": "sendgrid/sendgrid", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sendgrid/sendgrid-php.git", + "reference": "b659d96f19f69bcef6807300f88ac7a1b2449328" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sendgrid/sendgrid-php/zipball/b659d96f19f69bcef6807300f88ac7a1b2449328", + "reference": "b659d96f19f69bcef6807300f88ac7a1b2449328", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6", + "sendgrid/php-http-client": "~3.9" + }, + "replace": { + "sendgrid/sendgrid-php": "*" + }, + "require-dev": { + "phpunit/phpunit": "^5.7.9 || ^6.4.3", + "squizlabs/php_codesniffer": "3.*", + "swaggest/json-diff": "^3.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "SendGrid\\": "lib/", + "SendGrid\\Mail\\": "lib/mail/", + "SendGrid\\Contacts\\": "lib/contacts/", + "SendGrid\\Stats\\": "lib/stats/" + }, + "files": [ + "lib/SendGrid.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "This library allows you to quickly and easily send emails through SendGrid using PHP.", + "homepage": "http://github.com/sendgrid/sendgrid-php", + "keywords": [ + "email", + "grid", + "send", + "sendgrid" + ], + "time": "2018-05-19 16:38:14" + }, { "name": "symfony/config", "version": "v3.4.6",