Passed
Push — master ( d81d1a...2b951a )
by Nils
06:51 queued 10s
created

ItemController::getOtpAction()   D

Complexity

Conditions 16
Paths 199

Size

Total Lines 113
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 16
eloc 74
nc 199
nop 1
dl 0
loc 113
rs 4.7416
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Teampass - a collaborative passwords manager.
4
 * ---
5
 * This library is distributed in the hope that it will be useful,
6
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
7
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
8
 * ---
9
 *
10
 * @project   Teampass
11
 * @version    API
12
 *
13
 * @file      ItemControler.php
14
 * ---
15
 *
16
 * @author    Nils Laumaillé ([email protected])
17
 *
18
 * @copyright 2009-2025 Teampass.net
19
 *
20
 * @license   https://spdx.org/licenses/GPL-3.0-only.html#licenseText GPL-3.0
21
 * ---
22
 *
23
 * @see       https://www.teampass.net
24
 */
25
26
use Symfony\Component\HttpFoundation\Request AS symfonyRequest;
27
28
class ItemController extends BaseController
29
{
30
31
    /**
32
     * Get user's private key from database
33
     *
34
     * Retrieves the user's private key by decrypting it from the database.
35
     * The private key is stored ENCRYPTED in the database and is decrypted
36
     * using the session_key from the JWT token.
37
     *
38
     * @param array $userData User data from JWT token (must include id, key_tempo, and session_key)
39
     * @return string|null Decrypted private key or null if not found/invalid session
40
     */
41
    private function getUserPrivateKey(array $userData): ?string
42
    {
43
        include_once API_ROOT_PATH . '/inc/jwt_utils.php';
44
45
        // Verify session_key exists in JWT payload
46
        if (!isset($userData['session_key']) || empty($userData['session_key'])) {
47
            error_log('getUserPrivateKey: Missing session_key in JWT token for user ID ' . $userData['id']);
48
            return null;
49
        }
50
51
        $userKeys = get_user_keys(
52
            (int) $userData['id'],
53
            (string) $userData['key_tempo'],
54
            (string) $userData['session_key'] // Session key from JWT for decryption
55
        );
56
57
        if ($userKeys === null) {
58
            return null;
59
        }
60
61
        return $userKeys['private_key'];
62
    }
63
64
65
    /**
66
     * Manage case inFolder - get items inside an array of folders
67
     *
68
     * @param array $userData
69
     */
70
    public function inFoldersAction(array $userData): void
71
    {
72
        $request = symfonyRequest::createFromGlobals();
73
        $requestMethod = $request->getMethod();
74
        $strErrorDesc = $responseData = $strErrorHeader = '';
75
76
        // get parameters
77
        $arrQueryStringParams = $this->getQueryStringParams();
78
79
        if (strtoupper($requestMethod) === 'GET') {
80
            // define WHERE clause
81
            $sqlExtra = '';
82
            if (empty($userData['folders_list']) === false) {
83
                $userData['folders_list'] = explode(',', $userData['folders_list']);
84
            } else {
85
                $userData['folders_list'] = [];
86
            }
87
88
            // SQL where clause with folders list
89
            if (isset($arrQueryStringParams['folders']) === true) {
90
                // convert the folders to an array
91
                $arrQueryStringParams['folders'] = explode(',', str_replace( array('[',']') , ''  , $arrQueryStringParams['folders']));
92
93
                // ensure to only use the intersection
94
                $foldersList = implode(',', array_intersect($arrQueryStringParams['folders'], $userData['folders_list']));
95
96
                // build sql where clause
97
                if (!empty($foldersList)) {
98
                    // build sql where clause
99
                    $sqlExtra = ' WHERE id_tree IN ('.$foldersList.')';
100
                } else {
101
                    // Send error
102
                    $this->sendOutput(
103
                        json_encode(['error' => 'Folders are mandatory']),
104
                        ['Content-Type: application/json', 'HTTP/1.1 401 Expected parameters not provided']
105
                    );
106
                }
107
            } else {
108
                // Send error
109
                $this->sendOutput(
110
                    json_encode(['error' => 'Folders are mandatory']),
111
                    ['Content-Type: application/json', 'HTTP/1.1 401 Expected parameters not provided']
112
                );
113
            }
114
115
            // SQL LIMIT
116
            $intLimit = 0;
117
            if (isset($arrQueryStringParams['limit']) === true) {
118
                $intLimit = $arrQueryStringParams['limit'];
119
            }
120
121
            // send query
122
            try {
123
                $itemModel = new ItemModel();
124
125
                // Get user's private key from database
126
                $userPrivateKey = $this->getUserPrivateKey($userData);
127
                if ($userPrivateKey === null) {
128
                    $strErrorDesc = 'Invalid session or user keys not found';
129
                    $strErrorHeader = 'HTTP/1.1 401 Unauthorized';
130
                } else {
131
                    $arrItems = $itemModel->getItems($sqlExtra, $intLimit, $userPrivateKey, $userData['id']);
132
                    if (!empty($arrItems)) {
133
                        $responseData = json_encode($arrItems);
134
                    } else {
135
                        $strErrorDesc = 'No content for this label';
136
                        $strErrorHeader = 'HTTP/1.1 204 No Content';
137
                    }
138
                }
139
            } catch (Error $e) {
140
                $strErrorDesc = $e->getMessage().'. Something went wrong! Please contact support.4';
141
                $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
142
            }
143
        } else {
144
            $strErrorDesc = 'Method not supported';
145
            $strErrorHeader = 'HTTP/1.1 422 Unprocessable Entity';
146
        }
147
148
        // send output
149
        if (empty($strErrorDesc) === true) {
150
            $this->sendOutput(
151
                $responseData,
152
                ['Content-Type: application/json', 'HTTP/1.1 200 OK']
153
            );
154
        } else {
155
            $this->sendOutput(
156
                json_encode(['error' => $strErrorDesc]), 
157
                ['Content-Type: application/json', $strErrorHeader]
158
            );
159
        }
160
    }
161
    //end InFoldersAction()
162
163
    private function checkNewItemData(array $arrQueryStringParams, array $userData): array
164
    {
165
        if (isset($arrQueryStringParams['label']) === true
166
            && isset($arrQueryStringParams['folder_id']) === true
167
            && isset($arrQueryStringParams['password']) === true
168
            && isset($arrQueryStringParams['login']) === true
169
            && isset($arrQueryStringParams['email']) === true
170
            && isset($arrQueryStringParams['url']) === true
171
            && isset($arrQueryStringParams['tags']) === true
172
            && isset($arrQueryStringParams['anyone_can_modify']) === true
173
        ) {
174
            //
175
            if (in_array($arrQueryStringParams['folder_id'], $userData['folders_list']) === false && $userData['user_can_create_root_folder'] === 0) {
176
                return [
177
                    'error' => true,
178
                    'strErrorDesc' => 'User is not allowed in this folder',
179
                    'strErrorHeader' => 'HTTP/1.1 401 Unauthorized',
180
                ];
181
            } else if (empty($arrQueryStringParams['label']) === true) {
182
                return [
183
                    'error' => true,
184
                    'strErrorDesc' => 'Label is mandatory',
185
                    'strErrorHeader' => 'HTTP/1.1 401 Expected parameters not provided',
186
                ];
187
            } else {
188
                return [
189
                    'error' => false,
190
                ];
191
            }
192
        }
193
194
        return [
195
            'error' => true,
196
            'strErrorDesc' => 'All fields have to be provided even if empty (refer to documentation).',
197
            'strErrorHeader' => 'HTTP/1.1 401 Expected parameters not provided',
198
        ];
199
    }
200
201
    /**
202
     * Manage case Add
203
     *
204
     * @param array $userData
205
     */
206
    public function createAction(array $userData)
207
    {
208
        $request = symfonyRequest::createFromGlobals();
209
        $requestMethod = $request->getMethod();
210
        $strErrorDesc = $strErrorHeader = $responseData = '';
211
212
        if (strtoupper($requestMethod) === 'POST') {
213
            // Is user allowed to create a folder
214
            // We check if allowed_to_create
215
            if ((int) $userData['allowed_to_create'] !== 1) {
216
                $strErrorDesc = 'User is not allowed to create an item';
217
                $strErrorHeader = 'HTTP/1.1 401 Unauthorized';
218
            } else {
219
                if (empty($userData['folders_list']) === false) {
220
                    $userData['folders_list'] = explode(',', $userData['folders_list']);
221
                } else {
222
                    $userData['folders_list'] = [];
223
                }
224
225
                // get parameters
226
                $arrQueryStringParams = $this->getQueryStringParams();
227
228
                // Check that the parameters are indeed an array before using them
229
                if (is_array($arrQueryStringParams)) {
230
                    // check parameters
231
                    $arrCheck = $this->checkNewItemData($arrQueryStringParams, $userData);
232
233
                    if ($arrCheck['error'] === true) {
234
                        $strErrorDesc = $arrCheck['strErrorDesc'];
235
                        $strErrorHeader = $arrCheck['strErrorHeader'];
236
                    } else {
237
                        // launch
238
                        $itemModel = new ItemModel();
239
                        $ret = $itemModel->addItem(
240
                            (int) $arrQueryStringParams['folder_id'],
241
                            (string) $arrQueryStringParams['label'],
242
                            (string) $arrQueryStringParams['password'],
243
                            (string) $arrQueryStringParams['description'],
244
                            (string) $arrQueryStringParams['login'],
245
                            (string) $arrQueryStringParams['email'],
246
                            (string) $arrQueryStringParams['url'],
247
                            (string) $arrQueryStringParams['tags'],
248
                            (string) $arrQueryStringParams['anyone_can_modify'],
249
                            (string) $arrQueryStringParams['icon'],
250
                            (int) $userData['id'],
251
                            (string) $userData['username'],
252
                        );
253
                        $responseData = json_encode($ret);
254
                    }
255
                
256
                } else {
257
                    // Gérer le cas où les paramètres ne sont pas un tableau
258
                    $strErrorDesc = 'Data not consistent';
259
                    $strErrorHeader = 'Expected array, received ' . gettype($arrQueryStringParams);
260
                }
261
            }
262
        } else {
263
            $strErrorDesc = 'Method not supported';
264
            $strErrorHeader = 'HTTP/1.1 422 Unprocessable Entity';
265
        }
266
267
        // send output
268
        if (empty($strErrorDesc) === true) {
269
            $this->sendOutput(
270
                $responseData,
271
                ['Content-Type: application/json', 'HTTP/1.1 200 OK']
272
            );
273
        } else {
274
            $this->sendOutput(
275
                json_encode(['error' => $strErrorDesc]),
276
                ['Content-Type: application/json', $strErrorHeader]
277
            );
278
        }
279
    }
280
    //end addAction()
281
282
283
    /**
284
     * Manage case get - get an item
285
     *
286
     * @param array $userData
287
     */
288
    public function getAction(array $userData): void
289
    {
290
        
291
        $request = symfonyRequest::createFromGlobals();
292
        $requestMethod = $request->getMethod();
293
        $strErrorDesc = '';
294
        $sqlExtra = '';
295
        $responseData = '';
296
        $strErrorHeader = '';
297
        $sql_constraint = ' AND (i.id_tree IN ('.$userData['folders_list'].')';
298
        if (!empty($userData['restricted_items_list'])) {
299
            $sql_constraint .= 'OR i.id IN ('.$userData['restricted_items_list'].')';
300
        }
301
        $sql_constraint .= ')';
302
303
        // get parameters
304
        $arrQueryStringParams = $this->getQueryStringParams();
305
306
        if (strtoupper($requestMethod) === 'GET') {
307
            // SQL where clause with item id
308
            if (isset($arrQueryStringParams['id']) === true) {
309
                // build sql where clause by ID
310
                $sqlExtra = ' WHERE i.id = '.$arrQueryStringParams['id'] . $sql_constraint;
311
            } else if (isset($arrQueryStringParams['label']) === true) {
312
                // build sql where clause by LABEL
313
                $sqlExtra = ' WHERE i.label '.(isset($arrQueryStringParams['like']) === true && (int) $arrQueryStringParams['like'] === 1 ? ' LIKE '.$arrQueryStringParams['label'] : ' = '.$arrQueryStringParams['label']) . $sql_constraint;
314
            } else if (isset($arrQueryStringParams['description']) === true) {
315
                // build sql where clause by LABEL
316
                $sqlExtra = ' WHERE i.description '.(isset($arrQueryStringParams['like']) === true && (int) $arrQueryStringParams['like'] === 1 ? ' LIKE '.$arrQueryStringParams['description'] : ' = '.$arrQueryStringParams['description']).$sql_constraint;
317
            } else {
318
                // Send error
319
                $this->sendOutput(
320
                    json_encode(['error' => 'Item id, label or description is mandatory']),
321
                    ['Content-Type: application/json', 'HTTP/1.1 401 Expected parameters not provided']
322
                );
323
            }
324
325
            // send query
326
            try {
327
                $itemModel = new ItemModel();
328
329
                // Get user's private key from database
330
                $userPrivateKey = $this->getUserPrivateKey($userData);
331
                if ($userPrivateKey === null) {
332
                    $strErrorDesc = 'Invalid session or user keys not found';
333
                    $strErrorHeader = 'HTTP/1.1 401 Unauthorized';
334
                } else {
335
                    $arrItems = $itemModel->getItems($sqlExtra, 0, $userPrivateKey, $userData['id']);
336
                    $responseData = json_encode($arrItems);
337
                }
338
            } catch (Error $e) {
339
                $strErrorDesc = $e->getMessage().'. Something went wrong! Please contact support.6';
340
                $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
341
            }
342
        } else {
343
            $strErrorDesc = 'Method not supported';
344
            $strErrorHeader = 'HTTP/1.1 422 Unprocessable Entity';
345
        }
346
347
        // send output
348
        if (empty($strErrorDesc) === true) {
349
            $this->sendOutput(
350
                $responseData,
351
                ['Content-Type: application/json', 'HTTP/1.1 200 OK']
352
            );
353
        } else {
354
            $this->sendOutput(
355
                json_encode(['error' => $strErrorDesc]), 
356
                ['Content-Type: application/json', $strErrorHeader]
357
            );
358
        }
359
    }
360
    //end getAction()
361
362
    /**
363
 * Find items by URL
364
 * Searches for items matching a specific URL
365
 *
366
 * @param array $userData User data from JWT token
367
 * @return void
368
 */
369
public function findByUrlAction(array $userData): void
370
{
371
    $request = symfonyRequest::createFromGlobals();
372
    $requestMethod = $request->getMethod();
373
    $strErrorDesc = $responseData = $strErrorHeader = '';
374
375
    // Get parameters
376
    $arrQueryStringParams = $this->getQueryStringParams();
377
378
    if (strtoupper($requestMethod) === 'GET') {
379
        // Check if URL parameter is provided
380
        if (isset($arrQueryStringParams['url']) === false || empty($arrQueryStringParams['url']) === true) {
381
            $this->sendOutput(
382
                json_encode(['error' => 'URL parameter is mandatory']),
383
                ['Content-Type: application/json', 'HTTP/1.1 400 Bad Request']
384
            );
385
            return;
386
        }
387
388
        // Prepare user's accessible folders
389
        /*if (empty($userData['folders_list']) === false) {
390
            $userData['folders_list'] = explode(',', $userData['folders_list']);
391
        } else {
392
            $userData['folders_list'] = [];
393
        }*/
394
395
        // Build SQL constraint for accessible folders
396
        $sql_constraint = ' AND (i.id_tree IN (' . $userData['folders_list'] . ')';
397
        if (!empty($userData['restricted_items_list'])) {
398
            $sql_constraint .= ' OR i.id IN (' . $userData['restricted_items_list'] . ')';
399
        }
400
        $sql_constraint .= ')';
401
402
        // Decode URL if needed
403
        $searchUrl = urldecode($arrQueryStringParams['url']);
404
405
        try {
406
            // Get user's private key from database
407
            $userPrivateKey = $this->getUserPrivateKey($userData);
408
            if ($userPrivateKey === null) {
409
                $strErrorDesc = 'Invalid session or user keys not found';
410
                $strErrorHeader = 'HTTP/1.1 401 Unauthorized';
411
            } else {
412
                // Query items with the specific URL
413
                $rows = DB::query(
414
                    "SELECT i.id, i.label, i.login, i.url, i.id_tree, 
415
                            CASE WHEN o.enabled = 1 THEN 1 ELSE 0 END AS has_otp
416
                    FROM " . prefixTable('items') . " AS i
417
                    LEFT JOIN " . prefixTable('items_otp') . " AS o ON (o.item_id = i.id)
418
                    WHERE i.url LIKE %s" . $sql_constraint . "
419
                    ORDER BY i.label ASC",
420
                    "%".$searchUrl."%"
421
                );
422
423
                $ret = [];
424
                foreach ($rows as $row) {
425
                    // Get user's sharekey for this item
426
                    $shareKey = DB::queryfirstrow(
0 ignored issues
show
Unused Code introduced by
The assignment to $shareKey is dead and can be removed.
Loading history...
427
                        'SELECT share_key
428
                        FROM ' . prefixTable('sharekeys_items') . '
429
                        WHERE user_id = %i AND object_id = %i',
430
                        $userData['id'],
431
                        $row['id']
432
                    );
433
434
                    // Skip if no sharekey found (user doesn't have access)
435
                    if (DB::count() === 0) {
436
                        continue;
437
                    }
438
439
                    // Build response
440
                    array_push(
441
                        $ret,
442
                        [
443
                            'id' => (int) $row['id'],
444
                            'label' => $row['label'],
445
                            'login' => $row['login'],
446
                            'url' => $row['url'],
447
                            'folder_id' => (int) $row['id_tree'],
448
                            'has_otp' => (int) $row['has_otp'],
449
                        ]
450
                    );
451
                }
452
453
                if (!empty($ret)) {
454
                    $responseData = json_encode($ret);
455
                } else {
456
                    $strErrorDesc = 'No items found with this URL';
457
                    $strErrorHeader = 'HTTP/1.1 204 No Content';
458
                }
459
            }
460
        } catch (Error $e) {
461
            $strErrorDesc = $e->getMessage() . '. Something went wrong! Please contact support.';
462
            $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
463
        }
464
    } else {
465
        $strErrorDesc = 'Method not supported';
466
        $strErrorHeader = 'HTTP/1.1 422 Unprocessable Entity';
467
    }
468
469
    // Send output
470
    if (empty($strErrorDesc) === true) {
471
        $this->sendOutput(
472
            $responseData,
473
            ['Content-Type: application/json', 'HTTP/1.1 200 OK']
474
        );
475
    } else {
476
        $this->sendOutput(
477
            json_encode(['error' => $strErrorDesc]),
478
            ['Content-Type: application/json', $strErrorHeader]
479
        );
480
    }
481
}
482
//end findByUrlAction()
483
484
485
    /**
486
     * Get OTP/TOTP code for an item
487
     * Retrieves the current 6-digit TOTP code for an item with OTP enabled
488
     *
489
     * @param array $userData User data from JWT token
490
     * @return void
491
     */
492
    public function getOtpAction(array $userData): void
493
    {
494
        $request = symfonyRequest::createFromGlobals();
495
        $requestMethod = $request->getMethod();
496
        $strErrorDesc = '';
497
        $responseData = '';
498
        $strErrorHeader = '';
499
500
        // get parameters
501
        $arrQueryStringParams = $this->getQueryStringParams();
502
503
        if (strtoupper($requestMethod) === 'GET') {
504
            // Check if item ID is provided
505
            if (!isset($arrQueryStringParams['id']) || empty($arrQueryStringParams['id'])) {
506
                $this->sendOutput(
507
                    json_encode(['error' => 'Item id is mandatory']),
508
                    ['Content-Type: application/json', 'HTTP/1.1 400 Bad Request']
509
                );
510
                return;
511
            }
512
513
            $itemId = (int) $arrQueryStringParams['id'];
514
515
            try {
516
                // Load config
517
                loadClasses('DB');
518
519
                // Load item basic info to check folder access
520
                $itemInfo = DB::queryFirstRow(
521
                    'SELECT id_tree FROM ' . prefixTable('items') . ' WHERE id = %i',
522
                    $itemId
523
                );
524
525
                if (DB::count() === 0) {
526
                    $strErrorDesc = 'Item not found';
527
                    $strErrorHeader = 'HTTP/1.1 404 Not Found';
528
                } else {
529
                    // Check if user has access to the folder
530
                    $userFolders = !empty($userData['folders_list']) ? explode(',', $userData['folders_list']) : [];
531
                    $hasAccess = in_array((string) $itemInfo['id_tree'], $userFolders, true);
532
533
                    // Also check restricted items if applicable
534
                    if (!$hasAccess && !empty($userData['restricted_items_list'])) {
535
                        $restrictedItems = explode(',', $userData['restricted_items_list']);
536
                        $hasAccess = in_array((string) $itemId, $restrictedItems, true);
537
                    }
538
539
                    if (!$hasAccess) {
540
                        $strErrorDesc = 'Access denied to this item';
541
                        $strErrorHeader = 'HTTP/1.1 403 Forbidden';
542
                    } else {
543
                        // Load OTP data
544
                        $otpData = DB::queryFirstRow(
545
                            'SELECT secret, enabled FROM ' . prefixTable('items_otp') . ' WHERE item_id = %i',
546
                            $itemId
547
                        );
548
549
                        if (DB::count() === 0) {
550
                            $strErrorDesc = 'OTP not configured for this item';
551
                            $strErrorHeader = 'HTTP/1.1 404 Not Found';
552
                        } elseif ((int) $otpData['enabled'] !== 1) {
553
                            $strErrorDesc = 'OTP is not enabled for this item';
554
                            $strErrorHeader = 'HTTP/1.1 403 Forbidden';
555
                        } else {
556
                            // Decrypt the secret
557
                            $decryptedSecret = cryption(
558
                                $otpData['secret'],
559
                                '',
560
                                'decrypt'
561
                            );
562
563
                            if (isset($decryptedSecret['string']) && !empty($decryptedSecret['string'])) {
564
                                // Generate OTP code using OTPHP library
565
                                try {
566
                                    $otp = \OTPHP\TOTP::createFromSecret($decryptedSecret['string']);
567
                                    $otpCode = $otp->now();
568
                                    $otpExpiresIn = $otp->expiresIn();
569
570
                                    $responseData = json_encode([
571
                                        'otp_code' => $otpCode,
572
                                        'expires_in' => $otpExpiresIn,
573
                                        'item_id' => $itemId
574
                                    ]);
575
                                } catch (\RuntimeException $e) {
576
                                    $strErrorDesc = 'Failed to generate OTP code: ' . $e->getMessage();
577
                                    $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
578
                                }
579
                            } else {
580
                                $strErrorDesc = 'Failed to decrypt OTP secret';
581
                                $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
582
                            }
583
                        }
584
                    }
585
                }
586
            } catch (\Error $e) {
587
                $strErrorDesc = $e->getMessage() . '. Something went wrong! Please contact support.';
588
                $strErrorHeader = 'HTTP/1.1 500 Internal Server Error';
589
            }
590
        } else {
591
            $strErrorDesc = 'Method not supported';
592
            $strErrorHeader = 'HTTP/1.1 422 Unprocessable Entity';
593
        }
594
595
        // send output
596
        if (empty($strErrorDesc) === true) {
597
            $this->sendOutput(
598
                $responseData,
599
                ['Content-Type: application/json', 'HTTP/1.1 200 OK']
600
            );
601
        } else {
602
            $this->sendOutput(
603
                json_encode(['error' => $strErrorDesc]),
604
                ['Content-Type: application/json', $strErrorHeader]
605
            );
606
        }
607
    }
608
    //end getOtpAction()
609
610
611
}
612