• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / 49885719-c516-447d-96c8-ce59b253cca2

03 Jan 2025 06:28PM UTC coverage: 92.309% (-0.01%) from 92.321%
49885719-c516-447d-96c8-ce59b253cca2

push

circleci

edwh
Test fixes.

25504 of 27629 relevant lines covered (92.31%)

31.52 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.09
/include/session/Session.php
1
<?php
2
namespace Freegle\Iznik;
3

4
use Firebase\JWT\JWT;
5

6
$sessionPrepared = FALSE;
7

8
class Session {
9
    # Based on http://stackoverflow.com/questions/244882/what-is-the-best-way-to-implement-remember-me-for-a-website
10
    private $dbhr;
11
    private $dbhm;
12
    private $id;
13

14
    public static function modtools() {
15
        # Whether we are in ModTools or Freegle Direct.  For API calls this is passed as a request parameter.
16
        if (isset($_REQUEST) && array_key_exists('modtools', $_REQUEST)) {
490✔
17
            return filter_var($_REQUEST['modtools'], FILTER_VALIDATE_BOOLEAN);
489✔
18
        } else {
19
            # Default TRUE for scripts.
20
            return TRUE;
1✔
21
        }
22
    }
23

24
    public static function prepareSession($dbhr, $dbhm) {
25
        # We only want to do the prepare once, otherwise we will generate many headers.
26

27
        # Backbone may send requests wrapped insode a model; extract them.
28
        if (array_key_exists ( 'model', $_REQUEST )) {
606✔
29
            $_REQUEST = array_merge ( $_REQUEST, json_decode ( $_REQUEST ['model'], true ) );
×
30
        }
31

32
        if (!Utils::pres('sessionPrepared', $GLOBALS)) {
606✔
33
            $GLOBALS['sessionPrepared'] = TRUE;
606✔
34

35
            if (Utils::pres('api_key', $_REQUEST)) {
606✔
36
                # We have been passed a session id.
37
                #
38
                # One example of this is when we are called from Swagger.
39
                session_id($_REQUEST['api_key']);
×
40
            }
41

42
            # The IOS app needs to be able to set the session id.  This is as secure as the Cookie header would be.
43
            $headers = Session::getallheaders();
606✔
44
            $sessid = Utils::presdef('X-Iznik-Php-Session', $headers, NULL);
606✔
45
            if ($sessid && strlen($sessid) >= 26 && ctype_alnum($sessid)) {
606✔
46
                @session_destroy();
1✔
47
                session_id($sessid);
1✔
48
            }
49

50
            # We need to also be prepared to do a session_start here, because if we're running in the UT then the session_start
51
            # above will happen once at the start of the test, when the script is first included, and we will later on destroy
52
            # it.
53
            #error_log("prepare " . isset($_SESSION) . " id " . session_id());
54
            if (session_status() == PHP_SESSION_NONE) {
606✔
55
                # Don't return PHP session cookie.  There is a risk of conflict between different servers, and our
56
                # persistent/JWT code means we don't need it.
57
                ini_set('session.use_cookies', '0');
1✔
58
                @setcookie('PHPSESSID', '', -1, '/');
1✔
59
                @session_start();
1✔
60
            }
61

62
            # We might have a partner key which allows us access to the API when not logged in as a user.
63
            $_SESSION['partner'] = FALSE;
606✔
64

65
            if (Utils::pres('partner', $_REQUEST)) {
606✔
66
                list ($partner, $domain) = Session::partner($dbhr, $_REQUEST['partner']);
15✔
67
                $_SESSION['partner'] = $partner;
15✔
68
                $_SESSION['partnerdomain'] = $domain;
15✔
69
            }
70

71
            # Always verify the persistent session if passed.  This guards against
72
            # session id collisions, which can happen (albeit quite rarely).
73
            $cookie = Utils::presdef('persistent', $_REQUEST, NULL);
606✔
74
            if ($cookie && gettype($cookie) == 'string') {
606✔
75
                $cookie = json_decode($cookie, TRUE);
×
76
            }
77

78
            if (!$cookie) {
606✔
79
                # Check headers too.
80
                $headers = Session::getallheaders();
605✔
81
                if (Utils::pres('Authorization', $headers)) {
605✔
82
                    $auth = $headers['Authorization'];
5✔
83

84
                    if (strpos($auth, 'Iznik ') === 0) {
5✔
85
                        $cookie = json_decode(substr($auth, 6), TRUE);
5✔
86
                    }
87
                }
88
            }
89

90
            if ($cookie) {
606✔
91
                # Check our cookie to see if it's a valid session
92
                #error_log("Cookie " . var_export($cookie, TRUE));
93

94
                if ((array_key_exists('id', $cookie)) &&
8✔
95
                    (array_key_exists('series', $cookie)) &&
8✔
96
                    (array_key_exists('token', $cookie))
8✔
97
                ) {
98
                    $sesscook = Utils::presdef('persistent', $_SESSION, NULL);
8✔
99
                    #error_log("Check vs " . var_export($sesscook, TRUE));
100

101
                    if (Utils::pres('id', $_SESSION) && Utils::pres('userid', $cookie) && Utils::pres('userid', $cookie) != $_SESSION['id']) {
8✔
102
                        # The cookie we have been passed doesn't match the one we are logged in as.  That's a bit
103
                        # worrying.  Log it, and don't switch.
104
                        $msg = "Persistent session cookie doesn't match logged in user; logged in as {$_SESSION['id']} passed " . json_encode($cookie);
1✔
105
                        \Sentry\captureMessage($msg);
1✔
106
                        error_log($msg);
1✔
107
                    } else if (!Utils::presdef('id', $_SESSION, NULL) || $sesscook != $cookie) {
8✔
108
                        # We are not logged in as the correct user (or at all).  Try to switch to the persistent one.
109
                        #error_log("Logged in wrongly as " . var_export($sesscook, TRUE) . " when should be " . var_export($cookie, TRUE));
110
                        $_SESSION['id'] = NULL;
8✔
111
                        $s = new Session($dbhr, $dbhm);
8✔
112
                        $s->verify($cookie['id'], $cookie['series'], $cookie['token']);
8✔
113
                    }
114
                }
115
            }
116

117
            if (!Utils::pres('id', $_SESSION)) {
606✔
118
                # We might not have a cookie, but we might have push credentials.  This happens when we are logged out
119
                # on the client but get a notification.  That is sufficient to log us in.
120
                $pushcreds = Utils::presdef('pushcreds', $_REQUEST, NULL);
606✔
121
                #error_log("No session, pushcreds $pushcreds " . var_exporT($_REQUEST, TRUE));
122
                if ($pushcreds) {
606✔
123
                    $sql = "SELECT * FROM users_push_notifications WHERE subscription = ?;";
2✔
124
                    $pushes = $dbhr->preQuery($sql, [$pushcreds]);
2✔
125
                    foreach ($pushes as $push) {
2✔
126
                        $s = new Session($dbhr, $dbhm);
1✔
127
                        #error_log("Log in as {$push['userid']}");
128
                        $s->create($push['userid']);
1✔
129
                    }
130
                }
131
            }
132
        }
133
    }
134

135
    public static function getallheaders() {
136
        $headers = [];
630✔
137
        foreach ($_SERVER as $name => $value) {
630✔
138
            if (substr($name, 0, 5) == 'HTTP_') {
630✔
139
                $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
630✔
140
            }
141
        }
142
        return $headers;
630✔
143
    }
144

145
    public static function partner($dbhr, $key) {
146
        $ret = FALSE;
19✔
147
        $domain = NULL;
19✔
148

149
        $partners = $dbhr->preQuery("SELECT * FROM partners_keys WHERE `key` = ?;", [ $key ]);
19✔
150
        foreach ($partners as $partner) {
19✔
151
            $ret = $partner;
15✔
152
            $domain = $partner['domain'];
15✔
153
        }
154

155
        return([$ret, $domain]);
19✔
156
    }
157

158
    public static function whoAmId(LoggedPDO $dbhr, LoggedPDO $dbhm) {
159
        Session::prepareSession($dbhr, $dbhm);
605✔
160

161
        $id = Utils::presdef('id', $_SESSION, NULL);
605✔
162

163
        if ($id) {
605✔
164
            # Add context for Sentry exceptions.
165
            \Sentry\configureScope(function (\Sentry\State\Scope $scope) use ($id): void {
294✔
166
                $scope->setUser(['id' => $id]);
294✔
167
            });
294✔
168
        }
169
        return $id;
605✔
170
    }
171

172
    public static function whoAmI(LoggedPDO $dbhr, $dbhm)
173
    {
174
        $id = self::whoAmId($dbhr, $dbhm);
597✔
175
        $ret = NULL;
597✔
176
        #error_log("Session::whoAmI $id in " . session_id());
177

178
        if ($id) {
597✔
179
            # We are logged in.  Get our details
180
            $r = User::get($dbhr, $dbhm, $id);
290✔
181

182
            if ($r->getId() == $id) {
290✔
183
                #error_log("Return cached user $id");
184
                $ret = $r;
290✔
185
            }
186
            #error_log("Found " . $ret->getId() . " role " . $ret->isModerator());
187
        }
188

189
        return($ret);
597✔
190
    }
191

192
    public static function clearSessionCache() {
193
        # We cache some information.
194
        $_SESSION['notification'] = [];
465✔
195
        $_SESSION['modorowner'] = [];
465✔
196
    }
197

198
    public function getUserId() {
199
        $sql = "SELECT userid FROM sessions WHERE id = ?;";
2✔
200
        $sessions = $this->dbhr->preQuery($sql, [ $this->id ]);
2✔
201
        $ret = NULL;
2✔
202
        foreach ($sessions as $session) {
2✔
203
            $ret = $session['userid'];
2✔
204
        }
205

206
        return($ret);
2✔
207
    }
208

209
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm) {
210
        $this->dbhr = $dbhr;
289✔
211
        $this->dbhm = $dbhm;
289✔
212
    }
213

214
    public function create($userid, $supportAllowed = FALSE) {
215
        # Spammers can't create sessions, but we make it look as though they did.
216
        $s = new Spam($this->dbhr, $this->dbhm);
287✔
217

218
        if (!$s->isSpammerUid($userid, Spam::TYPE_SPAMMER)) {
287✔
219
            # If we wanted to only allow login from a single device/browser, we'd destroy cookies at this point.  But
220
            # we want to allow login on as many devices as the user wants.  We want to leave any existing sessions around
221
            # so that if they are used later on by other clients, they'll still work.  This can happen if they're stored
222
            # in local storage - we can get different sessions on different clients, and unless we allow them all, one
223
            # device can effectively log another one out.  They get tidied up via a cron script.
224
            # TODO SHA1 is no longer brilliantly secure.
225
            $series = Utils::devurandom_rand();
286✔
226
            $token  = Utils::devurandom_rand();
286✔
227
            $thash  = sha1($token);
286✔
228

229
            $sql = "INSERT INTO sessions (`userid`, `series`, `token`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id);";
286✔
230

231
            $this->dbhm->preExec($sql, [
286✔
232
                $userid,
286✔
233
                $series,
286✔
234
                $thash
286✔
235
            ]);
286✔
236

237
            $id = $this->dbhm->lastInsertId();
286✔
238
            $this->id = $id;
286✔
239

240
            $_SESSION['id'] = $userid;
286✔
241
            #error_log("Created session for $userid in " . session_id());
242
            $_SESSION['logged_in'] = TRUE;
286✔
243
            $_SESSION['supportAllowed'] = $supportAllowed;
286✔
244

245
            $_SESSION['persistent'] = [
286✔
246
                'id' => $id,
286✔
247
                'series' => $series,
286✔
248
                'token' => $thash,
286✔
249
                'userid' => $userid
286✔
250
            ];
286✔
251

252
            return ($_SESSION['persistent']);
286✔
253
        }
254

255
        return NULL;
1✔
256
    }
257

258
    public function verify($id, $series, $token) {
259
        # Look for a session.
260
        $sql = "SELECT * FROM sessions WHERE id = ? AND series = ? AND token = ?;";
9✔
261
        $sessions = $this->dbhr->preQuery($sql, [
9✔
262
            $id,
9✔
263
            $series,
9✔
264
            $token
9✔
265
        ]);
9✔
266

267
        #error_log("SELECT * FROM sessions WHERE id = $id AND series = $series AND token = '$token';");
268

269
        /** @noinspection PhpUnusedLocalVariableInspection */
270
        foreach ($sessions as $session) {
9✔
271
            # Leave the cookie in existence, marked as active.  This is a bit less secure, but it does mean that we
272
            # can frequently use the cookie to recover a session when the PHP session is no longer there, including
273
            # from multiple devices.
274
            $userid = $session['userid'];
4✔
275
            $_SESSION['id'] = $userid;
4✔
276
            $_SESSION['logged_in'] = TRUE;
4✔
277

278
            # Store this away in our PHP session, so that it gets returned the client, and will then work again
279
            # next time.
280
            $_SESSION['persistent'] = [
4✔
281
                'id' => $id,
4✔
282
                'series' => $series,
4✔
283
                'token' => $token,
4✔
284
                'userid' => $userid
4✔
285
            ];
4✔
286

287
            # Update the last access time, unless we have done so recently.
288
            $age = time() - strtotime($session['lastactive']);
4✔
289
            if ($age > 600) {
4✔
290
                $this->dbhm->preExec("UPDATE sessions SET lastactive = NOW() WHERE  id = ? AND series = ? AND token = ?;", [
×
291
                    $id,
×
292
                    $series,
×
293
                    $token
×
294
                ]);
×
295
            }
296

297
            return($userid);
4✔
298
        }
299

300
        # We failed to verify.  Some systems would zap the existing sessions here in case there was a theft, but
301
        # this means that a bad cookie on one device will log out other devices, which can create a ping-pong
302
        # from which you don't recover.
303

304
        return(NULL);
7✔
305
    }
306

307
    public function destroy($userid, $series) {
308
        # Deleting the cookie will mean that we can no longer use this cookie to sign in on any device - which means
309
        # that if you log out on one device, the others will get logged out too (once the PHP session goes, anyway).
310
        #error_log(var_export($this->dbhr, true));
311
        if ($userid) {
3✔
312
            # If we're doing an explicit logout we're called with a null $series and want to zap all sessions for this
313
            # user.  Otherwise we only want to delete the session with this series, otherwise a failed login for this
314
            # user would log out other users.
315
            $sql = $series ? "DELETE FROM sessions WHERE userid = ? AND series = ?;" : "DELETE FROM sessions WHERE userid = ?;";
3✔
316
            $parms = $series ? [ $userid, $series ] : [ $userid ];
3✔
317
            $this->dbhm->preExec($sql, $parms);
3✔
318

319
            $l = new Log($this->dbhr, $this->dbhm);
3✔
320
            $l->log([
3✔
321
                'type' => Log::TYPE_USER,
3✔
322
                'subtype' => Log::SUBTYPE_LOGOUT,
3✔
323
                'byuser' => $userid,
3✔
324
                'text' => "Series $series"
3✔
325
            ]);
3✔
326
        }
327

328
        $_SESSION['id'] = NULL;
3✔
329
        $_SESSION['logged_in'] = FALSE;
3✔
330
    }
331

332
    public static function JWT($dbhr, $dbhm) {
333
        $ret = NULL;
39✔
334

335
        # Generate a JWT for this user.
336
        $id = Session::whoAmId($dbhr, $dbhm);
39✔
337

338
        $privateKey = trim(file_get_contents('/etc/iznik_jwt_secret'));
39✔
339

340
        if ($privateKey) {
39✔
341
            // Create a JWT.  This can be very long-lived because the Go API will check it against the database.  We
342
            // need to do that check in order to be able to force logout.
343
            $ret = JWT::encode([
39✔
344
                'id' => $id . "",
39✔
345
                'sessionid' => $_SESSION['persistent']['id'] . "",
39✔
346
                'exp' => (time() + 365 * 30 * 24 * 60 * 60)
39✔
347
           ],
39✔
348
           $privateKey,
39✔
349
           'HS256');
39✔
350
        }
351

352
        return $ret;
39✔
353
    }
354
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc