AuthModel   A
last analyzed

Complexity

Total Complexity 30

Size/Duplication

Total Lines 337
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 152
dl 0
loc 337
rs 10
c 0
b 0
f 0
wmc 30

3 Methods

Rating   Name   Duplication   Size   Complexity  
C buildUserFoldersList() 0 109 17
A createUserJWT() 0 46 1
C getUserAuth() 0 122 12
1
<?php
2
/**
3
 * Teampass - a collaborative passwords manager.
4
 * ---
5
 * This file is part of the TeamPass project.
6
 * 
7
 * TeamPass is free software: you can redistribute it and/or modify it
8
 * under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation, version 3 of the License.
10
 * 
11
 * TeamPass is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU General Public License for more details.
15
 * 
16
 * You should have received a copy of the GNU General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 * 
19
 * Certain components of this file may be under different licenses. For
20
 * details, see the `licenses` directory or individual file headers.
21
 * ---
22
 * @version    API
23
 *
24
 * @file      AuthModel.php
25
 * @author    Nils Laumaillé ([email protected])
26
 * @copyright 2009-2025 Teampass.net
27
 * @license   GPL-3.0
28
 * @see       https://www.teampass.net
29
 */
30
31
use TeampassClasses\PasswordManager\PasswordManager;
32
use TeampassClasses\NestedTree\NestedTree;
33
use TeampassClasses\ConfigManager\ConfigManager;
34
use Firebase\JWT\JWT;
35
use Firebase\JWT\Key;
36
37
class AuthModel
38
{
39
40
41
    /**
42
     * Is the user allowed
43
     *
44
     * @param string $login
45
     * @param string $password
46
     * @param string $apikey
47
     * @return array
48
     */
49
    public function getUserAuth(string $login, string $password, string $apikey): array
50
    {
51
        // Sanitize
52
        // IMPORTANT: Password should NOT be escaped/sanitized - treat as opaque binary data
53
        // Only trim whitespace which is safe and expected (fix 3.1.5.10)
54
        include_once API_ROOT_PATH . '/../sources/main.functions.php';
55
        $inputData = dataSanitizer(
56
            [
57
                'login' => isset($login) === true ? $login : '',
58
                'password' => isset($password) === true ? $password : '',
59
                'apikey' => isset($apikey) === true ? $apikey : '',
60
            ],
61
            [
62
                'login' => 'trim|escape|strip_tags',
63
                'password' => 'trim', // Only trim, NO escape/sanitization
64
                'apikey' => 'trim|escape|strip_tags',
65
            ]
66
        );
67
        
68
        // Check apikey and credentials
69
        if (empty($inputData['login']) === true || empty($inputData['apikey']) === true || empty($inputData['password']) === true) {
70
            // case where it is a generic key
71
            // Not allowed to use this API
72
73
            return ["error" => "Login failed.", "info" => "User password is requested"];
74
        } else {
75
            // case where it is a user api key
76
            // Check if user exists
77
            $userInfo = getUserCompleteData($inputData['login']);
78
79
            if ($userInfo === null) {
80
                return ["error" => "Login failed.", "info" => "Credentials not valid"];
81
            }
82
83
            // Check if user is enabled
84
            if ((int) $userInfo['api_enabled'] === 0) {
85
                return ["error" => "Login failed.", "info" => "User not allowed to use API"];
86
            }
87
            
88
            // Check password
89
            $passwordManager = new PasswordManager();
90
            if ($passwordManager->verifyPassword($userInfo['pw'], $inputData['password']) === true) {
91
                // Correct credentials
92
                // get user keys
93
                $privateKeyClear = decryptPrivateKey($inputData['password'], (string) $userInfo['private_key']);
94
95
                // check API key
96
                if ($inputData['apikey'] !== base64_decode(decryptUserObjectKey($userInfo['api_key'], $privateKeyClear))) {
97
                    return ["error" => "Login failed.", "info" => "API Key not valid"];
98
                }
99
100
                // Update user's key_tempo
101
                $keyTempo = bin2hex(random_bytes(16));
102
                /*DB::update(
103
                    prefixTable('users'),
104
                    [
105
                        'key_tempo' => $keyTempo,
106
                    ],
107
                    'id = %i',
108
                    $userInfo['id']
109
                );*/
110
111
                // Generate a unique session key for this API session (256 bits / 32 bytes)
112
                // This key will be stored in the JWT and used to decrypt the private key
113
                $sessionKey = random_bytes(32);
114
                $sessionKeySalt = bin2hex(random_bytes(16));
115
116
                // Encrypt the decrypted private key with the session key
117
                // This allows us to store it securely in the database without exposing it
118
                require_once API_ROOT_PATH . '/inc/encryption_utils.php';
119
                $encryptedPrivateKey = encrypt_with_session_key($privateKeyClear, $sessionKey);
120
121
                if ($encryptedPrivateKey === false) {
122
                    return ["error" => "Login failed.", "info" => "Failed to encrypt private key"];
123
                }
124
125
                // Store the ENCRYPTED private key in the API table
126
                // Even if the database is compromised, the key cannot be used without the session_key from the JWT
127
                DB::update(
128
                    prefixTable('api'),
129
                    [
130
                        'encrypted_private_key' => $encryptedPrivateKey,
131
                        'session_key_salt' => $sessionKeySalt,
132
                        'session_key' => $keyTempo,
133
                        'timestamp' => time(),
134
                    ],
135
                    'user_id = %i',
136
                    $userInfo['id']
137
                );
138
139
                // get user folders list
140
                $ret = $this->buildUserFoldersList($userInfo);
141
142
                // Load config
143
                $configManager = new ConfigManager();
144
                $SETTINGS = $configManager->getAllSettings();
145
146
                // Log user
147
                logEvents($SETTINGS, 'api', 'user_connection', (string) $userInfo['id'], stripslashes($userInfo['login']));
148
149
                // create JWT with session key
150
                return $this->createUserJWT(
151
                    (int) $userInfo['id'],
152
                    (string) $inputData['login'],
153
                    (int) $userInfo['personal_folder'],
154
                    (string) implode(",", $ret['folders']),
155
                    (string) implode(",", $ret['items']),
156
                    (string) $keyTempo,
157
                    (string) base64_encode($sessionKey), // Session key for decrypting private key
158
                    (int) $userInfo['admin'],
159
                    (int) $userInfo['gestionnaire'],
160
                    (int) $userInfo['can_create_root_folder'],
161
                    (int) $userInfo['can_manage_all_users'],
162
                    (string) $userInfo['fonction_id'],
163
                    (string) $userInfo['api_allowed_folders'],
164
                    (int) $userInfo['api_allowed_to_create'],
165
                    (int) $userInfo['api_allowed_to_read'],
166
                    (int) $userInfo['api_allowed_to_update'],
167
                    (int) $userInfo['api_allowed_to_delete'],
168
                );
169
            } else {
170
                return ["error" => "Login failed.", "info" => "Credentials not valid"];
171
            }
172
        }
173
    }
174
    //end getUserAuth
175
176
    /**
177
     * Create a JWT
178
     *
179
     * Note: User encryption keys (public_key, private_key) are no longer stored in the JWT
180
     * to reduce token size. Instead, the private key is encrypted with a session key and
181
     * stored in the database. The session key is stored in the JWT (~44 bytes) which is
182
     * used to decrypt the private key when needed.
183
     *
184
     * Security approach (defense in depth):
185
     * - Encrypted private key in DB is useless without session_key
186
     * - session_key in JWT is useless without encrypted private key from DB
187
     * - Both are required together to access the decrypted private key
188
     *
189
     * @param integer $id
190
     * @param string $login
191
     * @param integer $pf_enabled
192
     * @param string $folders
193
     * @param string $items
194
     * @param string $keyTempo
195
     * @param string $sessionKey Session key (base64) for decrypting private key from DB
196
     * @param integer $admin
197
     * @param integer $manager
198
     * @param integer $can_create_root_folder
199
     * @param integer $can_manage_all_users
200
     * @param string $roles
201
     * @param string $allowed_folders
202
     * @param integer $allowed_to_create
203
     * @param integer $allowed_to_read
204
     * @param integer $allowed_to_update
205
     * @param integer $allowed_to_delete
206
     * @return array
207
     */
208
    private function createUserJWT(
209
        int $id,
210
        string $login,
211
        int $pf_enabled,
212
        string $folders,
213
        string $items,
214
        string $keyTempo,
215
        string $sessionKey,
216
        int $admin,
217
        int $manager,
218
        int $can_create_root_folder,
219
        int $can_manage_all_users,
220
        string $roles,
221
        string $allowed_folders,
222
        int $allowed_to_create,
223
        int $allowed_to_read,
224
        int $allowed_to_update,
225
        int $allowed_to_delete,
226
    ): array
227
    {
228
        // Load config
229
        $configManager = new ConfigManager();
230
        $SETTINGS = $configManager->getAllSettings();
231
232
		$payload = [
233
            'username' => $login,
234
            'id' => $id,
235
            'exp' => (time() + $SETTINGS['api_token_duration'] + 600),
236
            'pf_enabled' => $pf_enabled,
237
            'folders_list' => $folders,
238
            'restricted_items_list' => $items,
239
            'key_tempo' => $keyTempo,
240
            'session_key' => $sessionKey, // Session key for decrypting private key from database
241
            'is_admin' => $admin,
242
            'is_manager' => $manager,
243
            'user_can_create_root_folder' => $can_create_root_folder,
244
            'user_can_manage_all_users' => $can_manage_all_users,
245
            'roles' => $roles,
246
            'allowed_folders' => $allowed_folders,
247
            'allowed_to_create' => $allowed_to_create,
248
            'allowed_to_read' => $allowed_to_read,
249
            'allowed_to_update' => $allowed_to_update,
250
            'allowed_to_delete' => $allowed_to_delete,
251
        ];
252
253
        return ['token' => JWT::encode($payload, DB_PASSWD, 'HS256')];
254
    }
255
256
    //end createUserJWT
257
258
259
    /**
260
     * Permit to build the list of folders the user can access
261
     *
262
     * @param array $userInfo
263
     * @return array
264
     */
265
    private function buildUserFoldersList(array $userInfo): array
266
    {
267
        //Build tree
268
        $tree = new NestedTree(prefixTable('nested_tree'), 'id', 'parent_id', 'title');
269
        
270
        // Start by adding the manually added folders
271
        $allowedFolders = array_map('intval', explode(";", $userInfo['groupes_visibles']));
272
        $readOnlyFolders = [];
273
        $allowedFoldersByRoles = [];
274
        $restrictedFoldersForItems = [];
275
        $foldersLimited = [];
276
        $foldersLimitedFull = [];
277
        $restrictedItems = [];
278
        $personalFolders = [];
279
280
        $userFunctionId = explode(";", $userInfo['fonction_id']);
281
282
        // Get folders from the roles
283
        if (count($userFunctionId) > 0) {
284
            $rows = DB::query(
285
                'SELECT * 
286
                FROM ' . prefixTable('roles_values') . '
287
                WHERE role_id IN %li  AND type IN ("W", "ND", "NE", "NDNE", "R")',
288
                $userFunctionId
289
            );
290
            foreach ($rows as $record) {
291
                if ($record['type'] === 'R') {
292
                    array_push($readOnlyFolders, $record['folder_id']);
293
                } elseif (in_array($record['folder_id'], $allowedFolders) === false) {
294
                    array_push($allowedFoldersByRoles, $record['folder_id']);
295
                }
296
            }
297
            $allowedFoldersByRoles = array_unique($allowedFoldersByRoles);
298
            $readOnlyFolders = array_unique($readOnlyFolders);
299
            // Clean arrays
300
            foreach ($allowedFoldersByRoles as $value) {
301
                $key = array_search($value, $readOnlyFolders);
302
                if ($key !== false) {
303
                    unset($readOnlyFolders[$key]);
304
                }
305
            }
306
        }
307
        
308
        // Does this user is allowed to see other items
309
        $inc = 0;
310
        $rows = DB::query(
311
            'SELECT id, id_tree 
312
            FROM ' . prefixTable('items') . '
313
            WHERE restricted_to LIKE %s'.
314
            (count($userFunctionId) > 0 ? ' AND id_tree NOT IN %li' : ''),
315
            $userInfo['id'],
316
            count($userFunctionId) > 0 ? $userFunctionId : DB::sqleval('0')
317
        );
318
        foreach ($rows as $record) {
319
            // Exclude restriction on item if folder is fully accessible
320
            $restrictedFoldersForItems[$inc] = $record['id_tree'];
321
            ++$inc;
322
        }
323
324
        // Check for the users roles if some specific rights exist on items
325
        $rows = DB::query(
326
            'SELECT i.id_tree, r.item_id
327
            FROM ' . prefixTable('items') . ' AS i
328
            INNER JOIN ' . prefixTable('restriction_to_roles') . ' AS r ON (r.item_id=i.id)
329
            WHERE '.(count($userFunctionId) > 0 ? ' id_tree NOT IN %li AND ' : '').' i.id_tree != ""
330
            ORDER BY i.id_tree ASC',
331
            count($userFunctionId) > 0 ? $userFunctionId : DB::sqleval('0')
332
        );
333
        foreach ($rows as $record) {
334
            $foldersLimited[$record['id_tree']][$inc] = $record['item_id'];
335
            //array_push($foldersLimitedFull, $record['item_id']);
336
            array_push($restrictedItems, $record['item_id']);
337
            array_push($foldersLimitedFull, $record['id_tree']);
338
            ++$inc;
339
        }
340
341
        // Add all personal folders
342
        $rows = DB::queryFirstRow(
343
            'SELECT id 
344
            FROM ' . prefixTable('nested_tree') . '
345
            WHERE title = %i AND personal_folder = 1'.
346
            (count($userFunctionId) > 0 ? ' AND id NOT IN %li' : ''),
347
            $userInfo['id'],
348
            count($userFunctionId) > 0 ? $userFunctionId : DB::sqleval('0')
349
        );
350
        if (empty($rows['id']) === false) {
351
            array_push($personalFolders, $rows['id']);
352
            // get all descendants
353
            $ids = $tree->getDescendants($rows['id'], false, false, true);
354
            foreach ($ids as $id) {
355
                array_push($personalFolders, $id);
356
            }
357
        }
358
359
        // All folders visibles
360
        return [
361
            'folders' => array_unique(
362
            array_filter(
363
                array_merge(
364
                    $allowedFolders,
365
                    $foldersLimitedFull,
366
                    $allowedFoldersByRoles,
367
                    $restrictedFoldersForItems,
368
                    $readOnlyFolders,
369
                    $personalFolders
370
                )
371
                )
372
            ),
373
            'items' => array_unique($restrictedItems),
374
        ];
375
    }
376
    //end buildUserFoldersList
377
}