TokensTable.php 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. <?php
  2. namespace Tools\Model\Table;
  3. use Cake\Utility\Hash;
  4. use Tools\Utility\Random;
  5. /**
  6. * A generic model to hold tokens
  7. *
  8. * @author Mark Scherer
  9. * @license http://opensource.org/licenses/mit-license.php MIT
  10. * @method \Tools\Model\Entity\Token get($primaryKey, $options = [])
  11. * @method \Tools\Model\Entity\Token newEntity($data = null, array $options = [])
  12. * @method \Tools\Model\Entity\Token[] newEntities(array $data, array $options = [])
  13. * @method \Tools\Model\Entity\Token|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
  14. * @method \Tools\Model\Entity\Token patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
  15. * @method \Tools\Model\Entity\Token[] patchEntities($entities, array $data, array $options = [])
  16. * @method \Tools\Model\Entity\Token findOrCreate($search, callable $callback = null, $options = [])
  17. */
  18. class TokensTable extends Table {
  19. /**
  20. * @var string
  21. */
  22. public $displayField = 'key';
  23. /**
  24. * @var array
  25. */
  26. public $order = ['created' => 'DESC'];
  27. /**
  28. * @var int
  29. */
  30. public $defaultLength = 22;
  31. /**
  32. * @var int
  33. */
  34. public $validity = MONTH;
  35. /**
  36. * @var array
  37. */
  38. public $validate = [
  39. 'type' => [
  40. 'notBlank' => [
  41. 'rule' => 'notBlank',
  42. 'message' => 'valErrMandatoryField',
  43. ],
  44. ],
  45. 'key' => [
  46. 'notBlank' => [
  47. 'rule' => ['notBlank'],
  48. 'message' => 'valErrMandatoryField',
  49. 'last' => true,
  50. ],
  51. 'isUnique' => [
  52. 'rule' => ['isUnique'],
  53. 'message' => 'valErrTokenExists',
  54. ],
  55. ],
  56. 'content' => [
  57. 'maxLength' => [
  58. 'rule' => ['maxLength', 255],
  59. 'message' => ['valErrMaxCharacters {0}', 255],
  60. 'allowEmpty' => true
  61. ],
  62. ],
  63. 'used' => ['numeric']
  64. ];
  65. /**
  66. * Stores new key in DB
  67. *
  68. * Checks if this key is already used (should be unique in table)
  69. *
  70. * @param string $type Type: necessary
  71. * @param string|null $key Key: optional key, otherwise a key will be generated
  72. * @param mixed|null $uid Uid: optional (if used, only this user can use this key)
  73. * @param string|array|null $content Content: up to 255 characters of content may be added (optional)
  74. *
  75. * @return string|bool Key on success, boolean false otherwise
  76. */
  77. public function newKey($type, $key = null, $uid = null, $content = null) {
  78. if (!$type) {
  79. return false;
  80. }
  81. if (!$key) {
  82. $key = $this->generateKey($this->defaultLength);
  83. $keyLength = $this->defaultLength;
  84. } else {
  85. $keyLength = mb_strlen($key);
  86. }
  87. if (is_array($content)) {
  88. $content = json_encode($content);
  89. }
  90. $data = [
  91. 'type' => $type,
  92. 'user_id' => $uid,
  93. 'content' => (string)$content,
  94. 'key' => $key,
  95. ];
  96. $entity = $this->newEntity($data);
  97. $max = 99;
  98. while (!$this->save($entity)) {
  99. $entity['key'] = $this->generateKey($keyLength);
  100. $max--;
  101. if ($max === 0) {
  102. return false;
  103. }
  104. }
  105. return $entity['key'];
  106. }
  107. /**
  108. * UsesKey (only once!) - by KEY
  109. *
  110. * @param string $type : necessary
  111. * @param string $key : necessary
  112. * @param mixed|null $uid : needs to be provided if this key has a user_id stored
  113. * @param bool $treatUsedAsInvalid
  114. * @return \Tools\Model\Entity\Token|null Content - if successfully used or if already used (used=1), NULL otherwise.
  115. */
  116. public function useKey($type, $key, $uid = null, $treatUsedAsInvalid = false) {
  117. if (!$type || !$key) {
  118. return null;
  119. }
  120. $options = ['conditions' => [$this->getAlias() . '.key' => $key, $this->getAlias() . '.type' => $type]];
  121. if ($uid) {
  122. $options['conditions'][$this->getAlias() . '.user_id'] = $uid;
  123. }
  124. /** @var \Tools\Model\Entity\Token $tokenEntity */
  125. $tokenEntity = $this->find('all', $options)->first();
  126. if (!$tokenEntity) {
  127. return null;
  128. }
  129. if ($uid && !empty($tokenEntity['user_id']) && $tokenEntity['user_id'] != $uid) {
  130. // return $res; # more secure to fail here if user_id is not provided, but was submitted prev.
  131. return null;
  132. }
  133. // already used?
  134. if (!empty($tokenEntity['used'])) {
  135. if ($treatUsedAsInvalid) {
  136. return null;
  137. }
  138. // return true and let the application check what to do then
  139. return $tokenEntity;
  140. }
  141. // actually spend key (set to used)
  142. if ($this->spendKey($tokenEntity['id'])) {
  143. return $tokenEntity;
  144. }
  145. // no limit? we dont spend key then
  146. if (!empty($tokenEntity['unlimited'])) {
  147. return $tokenEntity;
  148. }
  149. return null;
  150. }
  151. /**
  152. * Sets Key to "used" (only once!) - directly by ID
  153. *
  154. * @param int $id Id of key to spend: necessary
  155. * @return bool Success
  156. */
  157. public function spendKey($id) {
  158. if (!$id) {
  159. return false;
  160. }
  161. //$expression = new \Cake\Database\Expression\QueryExpression(['used = used + 1', 'modified' => date(FORMAT_DB_DATETIME)]);
  162. $result = $this->updateAll(
  163. ['used = used + 1', 'modified' => date(FORMAT_DB_DATETIME)],
  164. ['id' => $id]
  165. );
  166. if ($result) {
  167. return true;
  168. }
  169. return false;
  170. }
  171. /**
  172. * Remove old/invalid keys
  173. * does not remove recently used ones (for proper feedback)!
  174. *
  175. * @return int Rows
  176. */
  177. public function garbageCollector() {
  178. $conditions = [
  179. $this->getAlias() . '.created <' => date(FORMAT_DB_DATETIME, time() - $this->validity),
  180. ];
  181. return $this->deleteAll($conditions);
  182. }
  183. /**
  184. * Get admin stats
  185. *
  186. * @return array
  187. */
  188. public function stats() {
  189. $keys = [];
  190. $keys['unused_valid'] = $this->find('count', ['conditions' => [$this->getAlias() . '.used' => 0, $this->getAlias() . '.created >=' => date(FORMAT_DB_DATETIME, time() - $this->validity)]]);
  191. $keys['used_valid'] = $this->find('count', ['conditions' => [$this->getAlias() . '.used' => 1, $this->getAlias() . '.created >=' => date(FORMAT_DB_DATETIME, time() - $this->validity)]]);
  192. $keys['unused_invalid'] = $this->find('count', ['conditions' => [$this->getAlias() . '.used' => 0, $this->getAlias() . '.created <' => date(FORMAT_DB_DATETIME, time() - $this->validity)]]);
  193. $keys['used_invalid'] = $this->find('count', ['conditions' => [$this->getAlias() . '.used' => 1, $this->getAlias() . '.created <' => date(FORMAT_DB_DATETIME, time() - $this->validity)]]);
  194. $types = $this->find('all', ['conditions' => [], 'fields' => ['DISTINCT type']])->toArray();
  195. $keys['types'] = !empty($types) ? Hash::extract($types, '{n}.type') : [];
  196. return $keys;
  197. }
  198. /**
  199. * Generator of secure random tokens.
  200. *
  201. * Note that it is best to use an even number for the length.
  202. *
  203. * @param int|null $length (defaults to defaultLength)
  204. * @return string Key
  205. */
  206. public function generateKey($length = null) {
  207. if (!$length) {
  208. $length = $this->defaultLength;
  209. }
  210. if (version_compare(PHP_VERSION, '7.0.0') >= 0) {
  211. $function = 'random_bytes';
  212. } elseif (extension_loaded('openssl')) {
  213. $function = 'openssl_random_pseudo_bytes';
  214. } else {
  215. trigger_error('Not secure', E_USER_DEPRECATED);
  216. return Random::pwd($length);
  217. }
  218. $value = bin2hex($function($length / 2));
  219. if (strlen($value) !== $length) {
  220. $value = str_pad($value, $length, '0');
  221. }
  222. return $value;
  223. }
  224. }