├── VERSION ├── config ├── bootstrap.php ├── routes.php └── Migrations │ └── 001_create_tokenize_tokens_table.php ├── src ├── Controller │ └── TokensController.php ├── Shell │ └── TokensShell.php └── Model │ ├── Entity │ └── Token.php │ ├── Table │ └── TokensTable.php │ └── Behavior │ └── TokenizeBehavior.php ├── LICENSE ├── composer.json └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | v0.0.0 2 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | loadModel('Muffin/Tokenize.Tokens') 17 | ->verify($this->request->getParam('token')); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Shell/TokensShell.php: -------------------------------------------------------------------------------- 1 | loadModel('Muffin/Tokenize.Tokens')->deleteAllExpiredOrUsed(); 16 | $this->out(__n('One token deleted', '{0} tokens deleted', $count, $count)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /config/routes.php: -------------------------------------------------------------------------------- 1 | 'Tokens', 11 | 'action' => 'verify', 12 | ]; 13 | $options = [ 14 | 'token' => '[a-zA-Z0-9]{' . $length .'}', 15 | 'pass' => ['token'], 16 | ]; 17 | $routes->connect('/verify/:token', $defaults, $options); 18 | }); 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Use Muffin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /config/Migrations/001_create_tokenize_tokens_table.php: -------------------------------------------------------------------------------- 1 | table('tokenize_tokens', [ 11 | 'id' => false, 12 | 'primary_key' => 'id', 13 | ]); 14 | 15 | $table->addColumn('id', 'integer', ['identity' => true, 'signed' => true]); 16 | $table->addColumn('token', 'string'); 17 | $table->addColumn('foreign_alias', 'string'); 18 | $table->addColumn('foreign_table', 'string'); 19 | $table->addColumn('foreign_key', 'string'); 20 | $table->addColumn('foreign_data', 'text'); 21 | $table->addColumn('status', 'boolean'); 22 | $table->addColumn('expired', 'datetime'); 23 | $table->addColumn('created', 'datetime'); 24 | $table->addColumn('modified', 'datetime'); 25 | 26 | $table->addPrimaryKey('id'); 27 | $table->addIndex('token', ['name' => 'TOKENIZE_TOKEN', 'type' => Index::UNIQUE]); 28 | $table->addIndex('status', ['name' => 'TOKENIZE_STATUS']); 29 | $table->addIndex([ 30 | 'foreign_alias', 31 | 'foreign_table', 32 | 'foreign_key', 33 | ], ['name' => 'TOKENIZE_MODEL']); 34 | 35 | $table->create(); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muffin/tokenize", 3 | "description": "Security Tokens", 4 | "type": "cakephp-plugin", 5 | "keywords": [ 6 | "cakephp", 7 | "muffin", 8 | "tokenize" 9 | ], 10 | "homepage": "https://github.com/usemuffin/tokenize", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Jad Bitar", 15 | "homepage": "http://jadb.io", 16 | "role": "Author" 17 | }, 18 | { 19 | "name": "ADmad", 20 | "homepage": "https://github.com/ADmad", 21 | "role": "Author" 22 | }, 23 | { 24 | "name": "Others", 25 | "homepage": "https://github.com/usemuffin/tokenize/graphs/contributors" 26 | } 27 | ], 28 | "support": { 29 | "issues": "https://github.com/usemuffin/tokenize/issues", 30 | "source": "https://github.com/usemuffin/tokenize" 31 | }, 32 | "require": { 33 | "cakephp/cakephp": "^3.5" 34 | }, 35 | "require-dev": { 36 | "cakephp/cakephp-codesniffer": "^3.0", 37 | "phpunit/phpunit": "^5.7.14|^6.0", 38 | "cakephp/migrations": "^1.0" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Muffin\\Tokenize\\": "src" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Muffin\\Tokenize\\Test\\": "tests" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Model/Entity/Token.php: -------------------------------------------------------------------------------- 1 | self::random(), 36 | 'status' => false, 37 | 'expired' => date('Y-m-d H:i:s', strtotime($lifetime)) 38 | ]; 39 | parent::__construct($properties, $options); 40 | } 41 | 42 | /** 43 | * Creates a secure random token. 44 | * 45 | * @param int|null $length Token length 46 | * @return string 47 | * @see http://stackoverflow.com/a/29137661/2020428 48 | */ 49 | public static function random($length = null) 50 | { 51 | if ($length === null) { 52 | $length = Configure::read('Muffin/Tokenize.length', self::DEFAULT_LENGTH); 53 | } 54 | 55 | return bin2hex(Security::randomBytes($length / 2)); 56 | } 57 | 58 | /** 59 | * To string 60 | * 61 | * @return string 62 | */ 63 | public function __toString() 64 | { 65 | return $this->token; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Model/Table/TokensTable.php: -------------------------------------------------------------------------------- 1 | setTable($table); 29 | $this->setPrimaryKey('id'); 30 | $this->setDisplayField('token'); 31 | 32 | $this->addBehavior('Timestamp'); 33 | } 34 | 35 | /** 36 | * Custom finder "token" 37 | * 38 | * @param \Cake\ORM\Query $query Query 39 | * @param array $options Options 40 | * @return \Cake\ORM\Query 41 | */ 42 | public function findToken(Query $query, array $options) 43 | { 44 | $options += [ 45 | 'token' => null, 46 | 'expired >' => new DateTime(), 47 | 'status' => false 48 | ]; 49 | 50 | return $query->where($options); 51 | } 52 | 53 | /** 54 | * Delete all expired or used tokens. 55 | * 56 | * @return int 57 | */ 58 | public function deleteAllExpiredOrUsed() 59 | { 60 | return $this->deleteAll(['OR' => [ 61 | 'expired <' => new DateTime(), 62 | 'status' => true, 63 | ]]); 64 | } 65 | 66 | /** 67 | * Verify token 68 | * 69 | * @param string $token Token 70 | * @return bool|\Cake\Datasource\EntityInterface 71 | */ 72 | public function verify($token) 73 | { 74 | $result = $this->find('token', compact('token'))->firstOrFail(); 75 | 76 | $event = $this->dispatchEvent('Muffin/Tokenize.beforeVerify', ['token' => $result]); 77 | if ($event->isStopped()) { 78 | return false; 79 | } 80 | 81 | if (!empty($result['foreign_data'])) { 82 | $table = $this->foreignTable($result); 83 | $fields = $result['foreign_data']; 84 | $conditions = [(string)$table->getPrimaryKey() => $result['foreign_key']]; 85 | $table->updateAll($fields, $conditions); 86 | } 87 | 88 | $result->set('status', true); 89 | $this->save($result); 90 | 91 | $this->dispatchEvent('Muffin/Tokenize.afterVerify', ['token' => $result]); 92 | 93 | return $result; 94 | } 95 | 96 | /** 97 | * @param \Muffin\Tokenize\Model\Entity\Token $token Token entity 98 | * @return \Cake\ORM\Table 99 | */ 100 | protected function foreignTable(Token $token) 101 | { 102 | $options = []; 103 | if (!TableRegistry::exists($token['foreign_alias'])) { 104 | $options = [ 105 | 'table' => $token['foreign_table'], 106 | ]; 107 | } 108 | 109 | return TableRegistry::get($token['foreign_alias'], $options); 110 | } 111 | 112 | /** 113 | * @param \Cake\Database\Schema\TableSchema $schema Schema 114 | * @return \Cake\Database\Schema\TableSchema 115 | */ 116 | protected function _initializeSchema(TableSchema $schema) 117 | { 118 | $schema->setColumnType('foreign_data', 'json'); 119 | 120 | return $schema; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Model/Behavior/TokenizeBehavior.php: -------------------------------------------------------------------------------- 1 | null, 19 | 'associationAlias' => 'Tokens', 20 | 'implementedEvents' => [ 21 | 'Model.beforeSave' => 'beforeSave', 22 | ] 23 | ]; 24 | 25 | /** 26 | * Verifies the configuration and associates the table to the 27 | * `tokenize_tokens` table. 28 | * 29 | * @param array $config Config 30 | * @return void 31 | */ 32 | public function initialize(array $config) 33 | { 34 | $this->verifyConfig(); 35 | 36 | $this->_table->hasMany($this->getConfig('associationAlias'), [ 37 | 'className' => 'Muffin/Tokenize.Tokens', 38 | 'foreignKey' => 'foreign_key', 39 | 'conditions' => [ 40 | 'foreign_alias' => $this->_table->getAlias(), 41 | 'foreign_table' => $this->_table->getTable(), 42 | ], 43 | 'dependent' => true, 44 | ]); 45 | } 46 | 47 | /** 48 | * @param \Cake\Event\Event $event Event 49 | * @param \Cake\Datasource\EntityInterface $entity Entity 50 | * @param \ArrayObject $options Options 51 | * @return void 52 | */ 53 | public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) 54 | { 55 | $data = $this->fields($entity); 56 | 57 | if (empty($data)) { 58 | return; 59 | } 60 | 61 | if ($entity->isNew()) { 62 | if (array_key_exists('Model.afterSave', $this->implementedEvents())) { 63 | $options['tokenize_fields'] = $data; 64 | } 65 | 66 | return; 67 | } 68 | 69 | $token = $this->tokenize($entity->{$this->_table->getPrimaryKey()}, $data); 70 | $this->_table->dispatchEvent('Model.afterTokenize', compact('entity', 'token')); 71 | } 72 | 73 | /** 74 | * @param \Cake\Event\Event $event Event 75 | * @param \Cake\Datasource\EntityInterface $entity Entity 76 | * @param \ArrayObject $options Options 77 | * @return void 78 | */ 79 | public function afterSave(Event $event, EntityInterface $entity, ArrayObject $options) 80 | { 81 | if (empty($options['tokenize_fields'])) { 82 | return; 83 | } 84 | 85 | $token = $this->tokenize($entity->{$this->_table->getPrimaryKey()}, $options['tokenize_fields']); 86 | $this->_table->dispatchEvent('Model.afterTokenize', compact('entity', 'token')); 87 | } 88 | 89 | /** 90 | * Creates a token for a data sample. 91 | * 92 | * @param int|string $id Id 93 | * @param array $data Data 94 | * @return mixed 95 | */ 96 | public function tokenize($id, array $data = []) 97 | { 98 | $assoc = $this->getConfig('associationAlias'); 99 | 100 | $tokenData = [ 101 | 'foreign_alias' => $this->_table->getAlias(), 102 | 'foreign_table' => $this->_table->getTable(), 103 | 'foreign_key' => $id, 104 | 'foreign_data' => $data, 105 | ]; 106 | $tokenData = array_filter($tokenData); 107 | if (!isset($tokenData['foreign_data'])) { 108 | $tokenData['foreign_data'] = []; 109 | } 110 | 111 | $table = $this->_table->$assoc; 112 | $token = $table->newEntity($tokenData); 113 | 114 | if (!$table->save($token)) { 115 | throw new \RuntimeException(); 116 | } 117 | 118 | return $token; 119 | } 120 | 121 | /** 122 | * Returns fields that have been marked as protected. 123 | * 124 | * @param \Cake\Datasource\EntityInterface $entity Entity 125 | * @return array 126 | */ 127 | public function fields(EntityInterface $entity) 128 | { 129 | $fields = []; 130 | foreach ((array)$this->getConfig('fields') as $field) { 131 | if (!$entity->isDirty($field)) { 132 | continue; 133 | } 134 | $fields[$field] = $entity->$field; 135 | $entity->setDirty($field, false); 136 | } 137 | 138 | return $fields; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tokenize 2 | 3 | [![Build Status](https://img.shields.io/travis/UseMuffin/Tokenize/master.svg?style=flat-square)](https://travis-ci.org/UseMuffin/Tokenize) 4 | [![Coverage Status](https://img.shields.io/codecov/c/github/UseMuffin/Tokenize.svg?style=flat-square)](https://codecov.io/github/UseMuffin/Tokenize) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/muffin/tokenize.svg?style=flat-square)](https://packagist.org/packages/muffin/tokenize) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 7 | 8 | Security tokens for CakePHP 3. 9 | 10 | ## Why? 11 | 12 | Ever wanted to force users to activate their account upon registration? 13 | 14 | Or maybe just a confirmation link when updating their credentials? 15 | 16 | Ok, ok - maybe before cancelling a subscription or better, before sending funds out. 17 | 18 | Well, now you can. Attach listeners to your models for sending out emails (or any other 19 | notification method of your choice), and you're good to go! 20 | 21 | ## Install 22 | 23 | Using [Composer][composer]: 24 | 25 | ``` 26 | composer require muffin/tokenize 27 | ``` 28 | 29 | You then need to load the plugin. You can use the shell command: 30 | 31 | ``` 32 | bin/cake plugin load Muffin/Tokenize --routes 33 | ``` 34 | 35 | or by manually adding statement shown below to `bootstrap.php`: 36 | 37 | ```php 38 | Plugin::load('Muffin/Tokenize', ['routes' => true]); 39 | ``` 40 | 41 | This will ensure that the route for `/verify/:token` style URL is configured. 42 | 43 | You can also customize the token's length, lifetime and table through `Configure` as 44 | shown below: 45 | 46 | ```php 47 | Configure::write('Muffin/Tokenize', [ 48 | 'lifetime' => '3 days', // Default value 49 | 'length' => 32, // Default value 50 | 'table' => 'tokenize_tokens', // Default value 51 | ]); 52 | ``` 53 | 54 | You will also need to create the required table. A migration file was 55 | added to help you with that: 56 | 57 | ```sh 58 | bin/cake migrations migrate --plugin Muffin/Tokenize 59 | ``` 60 | 61 | ## How it works 62 | 63 | When creating or updating a record, and if the data contains any *tokenized* field(s), a token 64 | will automatically be created along with the value of the field(s) in question. 65 | 66 | When this happens the `Model.afterTokenize` event is fired and passed the operation's related 67 | entity and the associated token that was created for it. 68 | 69 | The initial (save or update) operation resumes but without the *tokenized* fields. 70 | 71 | The *tokenized* fields will only be updated upon submission of their associated token. 72 | 73 | ## Usage 74 | 75 | To tokenize the `password` column on updates, add this to your `UsersTable`: 76 | 77 | ```php 78 | $this->addBehavior('Muffin/Tokenize.Tokenize', [ 79 | 'fields' => ['password'], 80 | ]); 81 | ``` 82 | 83 | If instead you wanted to have it create a token both on account creation and credentials update: 84 | 85 | ```php 86 | $this->addBehavior('Muffin/Tokenize.Tokenize', [ 87 | 'fields' => ['password'], 88 | 'implementedEvents' => [ 89 | 'Model.beforeSave' => 'beforeSave', 90 | 'Model.afterSave' => 'afterSave', 91 | ], 92 | ]); 93 | ``` 94 | 95 | Finally, if you just wish to create a token on the fly for other custom scenarios (i.e. password-less 96 | login), you can manually create a token: 97 | 98 | ```php 99 | $this->Users->tokenize($user['id']); 100 | ``` 101 | 102 | The above operation, will return a `Muffin\Tokenize\Model\Entity\Token` instance. 103 | 104 | To verify a token from a controller's action: 105 | 106 | ```php 107 | $result = $this->Users->Tokens->verify($token); 108 | ``` 109 | 110 | ## Patches & Features 111 | 112 | * Fork 113 | * Mod, fix 114 | * Test - this is important, so it's not unintentionally broken 115 | * Commit - do not mess with license, todo, version, etc. (if you do change any, bump them into commits of 116 | their own that I can ignore when I pull) 117 | * Pull request - bonus point for topic branches 118 | 119 | To ensure your PRs are considered for upstream, you MUST follow the [CakePHP coding standards][standards]. 120 | 121 | ## Bugs & Feedback 122 | 123 | http://github.com/usemuffin/tokenize/issues 124 | 125 | ## License 126 | 127 | Copyright (c) 2015, [Use Muffin][muffin] and licensed under [The MIT License][mit]. 128 | 129 | [cakephp]:http://cakephp.org 130 | [composer]:http://getcomposer.org 131 | [mit]:http://www.opensource.org/licenses/mit-license.php 132 | [muffin]:http://usemuffin.com 133 | [standards]:http://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html 134 | --------------------------------------------------------------------------------