friendship ended with social-app. php is my new best friend
1<?php
2/**
3 * Class FileStorage
4 *
5 * @created 26.03.2024
6 * @author smiley <smiley@chillerlan.net>
7 * @copyright 2024 smiley
8 * @license MIT
9 */
10declare(strict_types=1);
11
12namespace chillerlan\OAuth\Storage;
13
14use chillerlan\OAuth\OAuthOptions;
15use chillerlan\OAuth\Core\AccessToken;
16use chillerlan\Settings\SettingsContainerInterface;
17use chillerlan\Utilities\{Crypto, Directory, File};
18use Psr\Log\{LoggerInterface, NullLogger};
19use DirectoryIterator;
20use Throwable;
21use function dirname, implode, str_starts_with, substr, trim;
22use const DIRECTORY_SEPARATOR;
23
24/**
25 * Implements a filesystem storage adapter.
26 *
27 * Please note that the storage root directory needs permissions 0777 or `is_writable()` will fail.
28 * Subfolders created by this class will have permissions set to 0755.
29 *
30 * @see \is_writable()
31 * @see \chillerlan\OAuth\OAuthOptions::$fileStoragePath
32 */
33class FileStorage extends OAuthStorageAbstract{
34
35 final protected const ENCRYPT_FORMAT = Crypto::ENCRYPT_FORMAT_BINARY;
36
37 /**
38 * A *unique* ID to identify the user within your application, e.g. database row id or UUID
39 */
40 protected string|int $oauthUser;
41
42 /**
43 * OAuthStorageAbstract constructor.
44 */
45 public function __construct(
46 string|int $oauthUser,
47 OAuthOptions|SettingsContainerInterface $options = new OAuthOptions,
48 LoggerInterface $logger = new NullLogger,
49 ){
50 parent::__construct($options, $logger);
51
52 $this->oauthUser = trim((string)$oauthUser);
53
54 if($this->oauthUser === ''){
55 throw new OAuthStorageException('invalid OAuth user');
56 }
57
58 if(empty($this->options->fileStoragePath)){
59 throw new OAuthStorageException('no storage path given');
60 }
61
62 }
63
64
65 /*
66 * Access token
67 */
68
69 public function storeAccessToken(AccessToken $token, string $provider):static{
70 $this->saveFile($this->toStorage($token), $this::KEY_TOKEN, $provider);
71
72 return $this;
73 }
74
75 public function getAccessToken(string $provider):AccessToken{
76 return $this->fromStorage($this->loadFile($this::KEY_TOKEN, $provider));
77 }
78
79 public function hasAccessToken(string $provider):bool{
80 return File::exists($this->getFilepath($this::KEY_TOKEN, $provider));
81 }
82
83 public function clearAccessToken(string $provider):static{
84 $this->deleteFile($this::KEY_TOKEN, $provider);
85
86 return $this;
87 }
88
89 public function clearAllAccessTokens():static{
90 $this->deleteAll($this::KEY_TOKEN);
91
92 return $this;
93 }
94
95
96 /*
97 * CSRF state
98 */
99
100 public function storeCSRFState(string $state, string $provider):static{
101
102 if($this->options->useStorageEncryption === true){
103 $state = $this->encrypt($state);
104 }
105
106 $this->saveFile($state, $this::KEY_STATE, $provider);
107
108 return $this;
109 }
110
111 public function getCSRFState(string $provider):string{
112 $state = $this->loadFile($this::KEY_STATE, $provider);
113
114 if($this->options->useStorageEncryption === true){
115 return $this->decrypt($state);
116 }
117
118 return $state;
119 }
120
121 public function hasCSRFState(string $provider):bool{
122 return File::exists($this->getFilepath($this::KEY_STATE, $provider));
123 }
124
125 public function clearCSRFState(string $provider):static{
126 $this->deleteFile($this::KEY_STATE, $provider);
127
128 return $this;
129 }
130
131 public function clearAllCSRFStates():static{
132 $this->deleteAll($this::KEY_STATE);
133
134 return $this;
135 }
136
137
138 /*
139 * PKCE verifier
140 */
141
142 public function storeCodeVerifier(string $verifier, string $provider):static{
143
144 if($this->options->useStorageEncryption === true){
145 $verifier = $this->encrypt($verifier);
146 }
147
148 $this->saveFile($verifier, $this::KEY_VERIFIER, $provider);
149
150 return $this;
151 }
152
153 public function getCodeVerifier(string $provider):string{
154 $verifier = $this->loadFile($this::KEY_VERIFIER, $provider);
155
156 if($this->options->useStorageEncryption === true){
157 return $this->decrypt($verifier);
158 }
159
160 return $verifier;
161 }
162
163 public function hasCodeVerifier(string $provider):bool{
164 return File::exists($this->getFilepath($this::KEY_VERIFIER, $provider));
165 }
166
167 public function clearCodeVerifier(string $provider):static{
168 $this->deleteFile($this::KEY_VERIFIER, $provider);
169
170 return $this;
171 }
172
173 public function clearAllCodeVerifiers():static{
174 $this->deleteAll($this::KEY_VERIFIER);
175
176 return $this;
177 }
178
179
180 /*
181 * Common
182 */
183
184 /**
185 * fetched the content from a file
186 */
187 protected function loadFile(string $key, string $provider):string{
188 $path = $this->getFilepath($key, $provider);
189
190 try{
191 return File::load($path);
192 }
193 catch(Throwable){
194 throw new ItemNotFoundException($key);
195 }
196
197 }
198
199 /**
200 * saves the given data to a file
201 */
202 protected function saveFile(string $data, string $key, string $provider):void{
203 $path = $this->getFilepath($key, $provider);
204 $dir = dirname($path);
205
206 if(!Directory::exists($dir)){
207 Directory::create($dir, 0o755, true); // @codeCoverageIgnore
208 }
209
210 File::save($path, $data);
211 }
212
213 /**
214 * deletes an existing file
215 */
216 protected function deleteFile(string $key, string $provider):void{
217 File::delete($this->getFilepath($key, $provider));
218 }
219
220 /**
221 * deletes all matching files
222 */
223 protected function deleteAll(string $key):void{
224 foreach(new DirectoryIterator($this->options->fileStoragePath) as $finfo){
225 $name = $finfo->getFilename();
226
227 if(!$finfo->isDir() || str_starts_with($name, '.')){
228 continue;
229 }
230
231 $this->deleteFile($key, $name);
232 }
233 }
234
235 /**
236 * gets the file path for $key (token/state), $provider and the given oauth user ID
237 */
238 protected function getFilepath(string $key, string $provider):string{
239 $provider = $this->getProviderName($provider);
240 $hash = Crypto::sha256($provider.$this->oauthUser.$key);
241 $path = [$this->options->fileStoragePath, $provider];
242
243 for($i = 1; $i <= 2; $i++){ // @todo: subdir depth to options?
244 $path[] = substr($hash, 0, $i);
245 }
246
247 $path[] = $hash;
248
249 return implode(DIRECTORY_SEPARATOR, $path);
250 }
251
252}