1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2025
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: */
21:
22: namespace SimpleID\Crypt;
23:
24: use Branca\Branca;
25: use JsonException;
26: use SimpleID\Store\StoreManager;
27:
28: /**
29: * A security token generator based on the branca token specification.
30: *
31: * A security token contains signed and encrypted data which only the
32: * generator can decode. It is used for various purposes, such as:
33: *
34: * - encoding state data to be passed between HTTP requests
35: * - generating CSRF tokens
36: *
37: * The payload of the token is a JSON object with the following
38: * keys defined:
39: *
40: * - `i` - a random generated identifier for the token
41: * - `o` - token options, consisting of the sum of the OPTION_
42: * constants defined by this class
43: * - `p` - the payload
44: * - `s` (optional) - the session ID
45: *
46: * @see https://github.com/tuupola/branca-spec
47: */
48: class SecurityToken {
49: /** @var string */
50: static private $site_token = null;
51:
52: const OPTION_DEFAULT = 0;
53:
54: /** The security token is bound to the current session ID */
55: const OPTION_BIND_SESSION = 1;
56:
57: /** The security token can only be verified once */
58: const OPTION_NONCE = 2;
59:
60: /** @var Branca the branca token generator */
61: private $branca;
62:
63: /** @var array<string, mixed> the data (i.e. payload plus headers) to be encoded in
64: * the token */
65: private $data = null;
66:
67: /**
68: * Creates a token generator.
69: *
70: * The encryption and signing keys should be formatted as a 32-byte
71: * binary string
72: *
73: * @param string $key the encryption and signing keys as a base64url
74: * encoded string
75: */
76: function __construct($key = null) {
77: if ($key == null) {
78: if (self::$site_token === null) self::$site_token = (StoreManager::instance())->getKey('site-token');
79: $key = self::$site_token;
80: }
81:
82: // Decode from base64url
83: $this->branca = new Branca(base64_decode(strtr($key, '-_', '+/')));
84: }
85:
86: /**
87: * Checks whether the token string is valid and if so, obtains the payload.
88: *
89: * @param string $token the token string
90: * @param int $ttl the number of seconds from which the token is considered
91: * expired
92: * @return mixed the payload or NULL if the security token is not valid
93: */
94: public function getPayload($token, $ttl = null) {
95: try {
96: $message = $this->branca->decode($token, $ttl);
97: } catch (\RuntimeException $e) {
98: return null;
99: }
100:
101: $decompressed = gzuncompress($message);
102: if ($decompressed == false) return null;
103:
104: $this->data = json_decode($decompressed, true);
105:
106: if (($this->data['o'] & self::OPTION_BIND_SESSION) == self::OPTION_BIND_SESSION) {
107: if (!isset($this->data['s'])) return null;
108: if ($this->data['s'] != session_id()) return null;
109: }
110:
111: if (($this->data['o'] & self::OPTION_NONCE) == self::OPTION_NONCE) {
112: if (!isset($this->data['i'])) return null;
113:
114: $cache = \Cache::instance();
115: $cache_name = rawurlencode($this->data['i']) . '.token';
116:
117: if (!$cache->exists($cache_name)) return null;
118: $cache_token = $cache->get($cache_name);
119: $cache->clear($cache_name);
120: if ($token != $cache_token) return null;
121: }
122:
123: if (!isset($this->data['p'])) return null;
124: return $this->data['p'];
125: }
126:
127: /**
128: * Convenience function to verify a token whose payload is a simple
129: * string
130: *
131: * @param string $token the token string
132: * @param string $expected the expected payload
133: * @param int $ttl the number of seconds from which the token is considered
134: * expired
135: * @return bool true if the token is valid and the payload matches the expected
136: * string
137: */
138: public function verify($token, $expected, $ttl = null) {
139: return ($this->getPayload($token, $ttl) == $expected);
140: }
141:
142: /**
143: * Generates a token.
144: *
145: * Note that as part of creating the token, `$payload` is encoded using
146: * `json_encode()`. Therefore `$payload` must be capable of being
147: * JSON encoded. In particular, all strings within `$payload` must be
148: * UTF-8 encoded.
149: *
150: * @param mixed $payload the payload to include in the token
151: * @param int $options the options for generating the token
152: * @return string the token string
153: */
154: public function generate($payload, $options = self::OPTION_DEFAULT) {
155: $rand = new Random();
156: $this->data = [
157: 'i' => $rand->id(),
158: 'o' => $options,
159: 'p' => $payload
160: ];
161: if (($options & self::OPTION_BIND_SESSION) == self::OPTION_BIND_SESSION) {
162: $this->data['s'] = session_id();
163: }
164:
165: try {
166: $encoded = json_encode($this->data, JSON_THROW_ON_ERROR);
167:
168: $compressed = gzcompress($encoded);
169: if ($compressed == false) return new \RuntimeException();
170:
171: $token = $this->branca->encode($compressed);
172:
173: if (($options & self::OPTION_NONCE) == self::OPTION_NONCE) {
174: $cache = \Cache::instance();
175: $cache_name = rawurlencode($this->data['i']) . '.token';
176: $cache->set($cache_name, $token, SIMPLEID_HUMAN_TOKEN_EXPIRES_IN);
177: }
178:
179: return $token;
180: } catch (\JsonException $e) {
181: return new \RuntimeException($e->getMessage(), 0, $e);
182: }
183: }
184: }
185:
186: ?>