aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorVirtualTam <virtualtam@flibidi.net>2018-04-27 23:17:38 +0200
committerVirtualTam <virtualtam@flibidi.net>2018-06-02 16:46:06 +0200
commit51f0128cdba52099c40693379e72f094b42a6f80 (patch)
tree57f71dc7d38611aaf91e77703acfd7ffbd0ac7c1
parentfab87c2696b9d6a26310f1bfc024b018ca5184fe (diff)
downloadShaarli-51f0128cdba52099c40693379e72f094b42a6f80.tar.gz
Shaarli-51f0128cdba52099c40693379e72f094b42a6f80.tar.zst
Shaarli-51f0128cdba52099c40693379e72f094b42a6f80.zip
Refactor session and cookie timeout control
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
-rw-r--r--application/security/LoginManager.php5
-rw-r--r--application/security/SessionManager.php48
-rw-r--r--index.php47
-rw-r--r--tests/security/SessionManagerTest.php181
4 files changed, 224 insertions, 57 deletions
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
index e7b9b21e..27247f3f 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -49,13 +49,12 @@ class LoginManager
49 * Check user session state and validity (expiration) 49 * Check user session state and validity (expiration)
50 * 50 *
51 * @param array $cookie The $_COOKIE array 51 * @param array $cookie The $_COOKIE array
52 * @param string $webPath Path on the server in which the cookie will be available on
53 * @param string $clientIpId Client IP address identifier 52 * @param string $clientIpId Client IP address identifier
54 * @param string $token Session token 53 * @param string $token Session token
55 * 54 *
56 * @return bool true if the user session is valid, false otherwise 55 * @return bool true if the user session is valid, false otherwise
57 */ 56 */
58 public function checkLoginState($cookie, $webPath, $clientIpId, $token) 57 public function checkLoginState($cookie, $clientIpId, $token)
59 { 58 {
60 if (! $this->configManager->exists('credentials.login')) { 59 if (! $this->configManager->exists('credentials.login')) {
61 // Shaarli is not configured yet 60 // Shaarli is not configured yet
@@ -73,7 +72,7 @@ class LoginManager
73 if ($this->sessionManager->hasSessionExpired() 72 if ($this->sessionManager->hasSessionExpired()
74 || $this->sessionManager->hasClientIpChanged($clientIpId) 73 || $this->sessionManager->hasClientIpChanged($clientIpId)
75 ) { 74 ) {
76 $this->sessionManager->logout($webPath); 75 $this->sessionManager->logout();
77 $this->isLoggedIn = false; 76 $this->isLoggedIn = false;
78 return; 77 return;
79 } 78 }
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
index 6f004b24..0dcd7f90 100644
--- a/application/security/SessionManager.php
+++ b/application/security/SessionManager.php
@@ -9,7 +9,10 @@ use Shaarli\Config\ConfigManager;
9class SessionManager 9class SessionManager
10{ 10{
11 /** @var int Session expiration timeout, in seconds */ 11 /** @var int Session expiration timeout, in seconds */
12 public static $INACTIVITY_TIMEOUT = 3600; 12 public static $SHORT_TIMEOUT = 3600; // 1 hour
13
14 /** @var int Session expiration timeout, in seconds */
15 public static $LONG_TIMEOUT = 31536000; // 1 year
13 16
14 /** @var string Name of the cookie set after logging in **/ 17 /** @var string Name of the cookie set after logging in **/
15 public static $LOGGED_IN_COOKIE = 'shaarli_staySignedIn'; 18 public static $LOGGED_IN_COOKIE = 'shaarli_staySignedIn';
@@ -20,6 +23,9 @@ class SessionManager
20 /** @var ConfigManager Configuration Manager instance **/ 23 /** @var ConfigManager Configuration Manager instance **/
21 protected $conf = null; 24 protected $conf = null;
22 25
26 /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */
27 protected $staySignedIn = false;
28
23 /** 29 /**
24 * Constructor 30 * Constructor
25 * 31 *
@@ -33,6 +39,16 @@ class SessionManager
33 } 39 }
34 40
35 /** 41 /**
42 * Define whether the user should stay signed in across browser sessions
43 *
44 * @param bool $staySignedIn Keep the user signed in
45 */
46 public function setStaySignedIn($staySignedIn)
47 {
48 $this->staySignedIn = $staySignedIn;
49 }
50
51 /**
36 * Generates a session token 52 * Generates a session token
37 * 53 *
38 * @return string token 54 * @return string token
@@ -104,7 +120,7 @@ class SessionManager
104 $this->session['uid'] = sha1(uniqid('', true) . '_' . mt_rand()); 120 $this->session['uid'] = sha1(uniqid('', true) . '_' . mt_rand());
105 $this->session['ip'] = $clientIpId; 121 $this->session['ip'] = $clientIpId;
106 $this->session['username'] = $this->conf->get('credentials.login'); 122 $this->session['username'] = $this->conf->get('credentials.login');
107 $this->session['expires_on'] = time() + self::$INACTIVITY_TIMEOUT; 123 $this->extendTimeValidityBy(self::$SHORT_TIMEOUT);
108 } 124 }
109 125
110 /** 126 /**
@@ -112,12 +128,24 @@ class SessionManager
112 */ 128 */
113 public function extendSession() 129 public function extendSession()
114 { 130 {
115 if (! empty($this->session['longlastingsession'])) { 131 if ($this->staySignedIn) {
116 // "Stay signed in" is enabled 132 return $this->extendTimeValidityBy(self::$LONG_TIMEOUT);
117 $this->session['expires_on'] = time() + $this->session['longlastingsession'];
118 return;
119 } 133 }
120 $this->session['expires_on'] = time() + self::$INACTIVITY_TIMEOUT; 134 return $this->extendTimeValidityBy(self::$SHORT_TIMEOUT);
135 }
136
137 /**
138 * Extend expiration time
139 *
140 * @param int $duration Expiration time extension (seconds)
141 *
142 * @return int New session expiration time
143 */
144 protected function extendTimeValidityBy($duration)
145 {
146 $expirationTime = time() + $duration;
147 $this->session['expires_on'] = $expirationTime;
148 return $expirationTime;
121 } 149 }
122 150
123 /** 151 /**
@@ -125,19 +153,17 @@ class SessionManager
125 * 153 *
126 * See: 154 * See:
127 * - https://secure.php.net/manual/en/function.setcookie.php 155 * - https://secure.php.net/manual/en/function.setcookie.php
128 *
129 * @param string $webPath path on the server in which the cookie will be available on
130 */ 156 */
131 public function logout($webPath) 157 public function logout()
132 { 158 {
133 if (isset($this->session)) { 159 if (isset($this->session)) {
134 unset($this->session['uid']); 160 unset($this->session['uid']);
135 unset($this->session['ip']); 161 unset($this->session['ip']);
162 unset($this->session['expires_on']);
136 unset($this->session['username']); 163 unset($this->session['username']);
137 unset($this->session['visibility']); 164 unset($this->session['visibility']);
138 unset($this->session['untaggedonly']); 165 unset($this->session['untaggedonly']);
139 } 166 }
140 setcookie(self::$LOGGED_IN_COOKIE, 'false', 0, $webPath);
141 } 167 }
142 168
143 /** 169 /**
diff --git a/index.php b/index.php
index 139812d7..8e3bade0 100644
--- a/index.php
+++ b/index.php
@@ -179,7 +179,7 @@ if (! is_file($conf->getConfigFileExt())) {
179// a token depending of deployment salt, user password, and the current ip 179// a token depending of deployment salt, user password, and the current ip
180define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['REMOTE_ADDR'] . $conf->get('credentials.salt'))); 180define('STAY_SIGNED_IN_TOKEN', sha1($conf->get('credentials.hash') . $_SERVER['REMOTE_ADDR'] . $conf->get('credentials.salt')));
181 181
182$loginManager->checkLoginState($_COOKIE, WEB_PATH, $clientIpId, STAY_SIGNED_IN_TOKEN); 182$loginManager->checkLoginState($_COOKIE, $clientIpId, STAY_SIGNED_IN_TOKEN);
183 183
184/** 184/**
185 * Adapter function to ensure compatibility with third-party templates 185 * Adapter function to ensure compatibility with third-party templates
@@ -205,31 +205,35 @@ if (isset($_POST['login'])) {
205 && $sessionManager->checkToken($_POST['token']) 205 && $sessionManager->checkToken($_POST['token'])
206 && $loginManager->checkCredentials($_SERVER['REMOTE_ADDR'], $clientIpId, $_POST['login'], $_POST['password']) 206 && $loginManager->checkCredentials($_SERVER['REMOTE_ADDR'], $clientIpId, $_POST['login'], $_POST['password'])
207 ) { 207 ) {
208 // Login/password is OK.
209 $loginManager->handleSuccessfulLogin($_SERVER); 208 $loginManager->handleSuccessfulLogin($_SERVER);
210 209
211 // If user wants to keep the session cookie even after the browser closes: 210 $cookiedir = '';
212 if (!empty($_POST['longlastingsession'])) { 211 if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
213 $_SESSION['longlastingsession'] = 31536000; // (31536000 seconds = 1 year)
214 $expiration = time() + $_SESSION['longlastingsession']; // calculate relative cookie expiration (1 year from now)
215 setcookie($sessionManager::$LOGGED_IN_COOKIE, STAY_SIGNED_IN_TOKEN, $expiration, WEB_PATH);
216 $_SESSION['expires_on'] = $expiration; // Set session expiration on server-side.
217
218 $cookiedir = '';
219 if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
220 $cookiedir = dirname($_SERVER["SCRIPT_NAME"]) . '/';
221 }
222 session_set_cookie_params($_SESSION['longlastingsession'],$cookiedir,$_SERVER['SERVER_NAME']); // Set session cookie expiration on client side
223 // Note: Never forget the trailing slash on the cookie path! 212 // Note: Never forget the trailing slash on the cookie path!
224 session_regenerate_id(true); // Send cookie with new expiration date to browser. 213 $cookiedir = dirname($_SERVER["SCRIPT_NAME"]) . '/';
225 } 214 }
226 else // Standard session expiration (=when browser closes) 215
227 { 216 if (!empty($_POST['longlastingsession'])) {
228 $cookiedir = ''; if(dirname($_SERVER['SCRIPT_NAME'])!='/') $cookiedir=dirname($_SERVER["SCRIPT_NAME"]).'/'; 217 // Keep the session cookie even after the browser closes
229 session_set_cookie_params(0,$cookiedir,$_SERVER['SERVER_NAME']); // 0 means "When browser closes" 218 $sessionManager->setStaySignedIn(true);
230 session_regenerate_id(true); 219 $expirationTime = $sessionManager->extendSession();
220
221 setcookie(
222 $sessionManager::$LOGGED_IN_COOKIE,
223 STAY_SIGNED_IN_TOKEN,
224 $expirationTime,
225 WEB_PATH
226 );
227
228 } else {
229 // Standard session expiration (=when browser closes)
230 $expirationTime = 0;
231 } 231 }
232 232
233 // Send cookie with the new expiration date to the browser
234 session_set_cookie_params($expirationTime, $cookiedir, $_SERVER['SERVER_NAME']);
235 session_regenerate_id(true);
236
233 // Optional redirect after login: 237 // Optional redirect after login:
234 if (isset($_GET['post'])) { 238 if (isset($_GET['post'])) {
235 $uri = '?post='. urlencode($_GET['post']); 239 $uri = '?post='. urlencode($_GET['post']);
@@ -590,7 +594,8 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
590 if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=logout')) 594 if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=logout'))
591 { 595 {
592 invalidateCaches($conf->get('resource.page_cache')); 596 invalidateCaches($conf->get('resource.page_cache'));
593 $sessionManager->logout(WEB_PATH); 597 $sessionManager->logout();
598 setcookie(SessionManager::$LOGGED_IN_COOKIE, 'false', 0, WEB_PATH);
594 header('Location: ?'); 599 header('Location: ?');
595 exit; 600 exit;
596 } 601 }
diff --git a/tests/security/SessionManagerTest.php b/tests/security/SessionManagerTest.php
index e4e1cfbc..e1c72707 100644
--- a/tests/security/SessionManagerTest.php
+++ b/tests/security/SessionManagerTest.php
@@ -14,11 +14,17 @@ use \PHPUnit\Framework\TestCase;
14 */ 14 */
15class SessionManagerTest extends TestCase 15class SessionManagerTest extends TestCase
16{ 16{
17 // Session ID hashes 17 /** @var array Session ID hashes */
18 protected static $sidHashes = null; 18 protected static $sidHashes = null;
19 19
20 // Fake ConfigManager 20 /** @var FakeConfigManager ConfigManager substitute for testing */
21 protected static $conf = null; 21 protected $conf = null;
22
23 /** @var array $_SESSION array for testing */
24 protected $session = [];
25
26 /** @var SessionManager Server-side session management abstraction */
27 protected $sessionManager = null;
22 28
23 /** 29 /**
24 * Assign reference data 30 * Assign reference data
@@ -26,7 +32,20 @@ class SessionManagerTest extends TestCase
26 public static function setUpBeforeClass() 32 public static function setUpBeforeClass()
27 { 33 {
28 self::$sidHashes = ReferenceSessionIdHashes::getHashes(); 34 self::$sidHashes = ReferenceSessionIdHashes::getHashes();
29 self::$conf = new FakeConfigManager(); 35 }
36
37 /**
38 * Initialize or reset test resources
39 */
40 public function setUp()
41 {
42 $this->conf = new FakeConfigManager([
43 'credentials.login' => 'johndoe',
44 'credentials.salt' => 'salt',
45 'security.session_protection_disabled' => false,
46 ]);
47 $this->session = [];
48 $this->sessionManager = new SessionManager($this->session, $this->conf);
30 } 49 }
31 50
32 /** 51 /**
@@ -34,12 +53,9 @@ class SessionManagerTest extends TestCase
34 */ 53 */
35 public function testGenerateToken() 54 public function testGenerateToken()
36 { 55 {
37 $session = []; 56 $token = $this->sessionManager->generateToken();
38 $sessionManager = new SessionManager($session, self::$conf);
39
40 $token = $sessionManager->generateToken();
41 57
42 $this->assertEquals(1, $session['tokens'][$token]); 58 $this->assertEquals(1, $this->session['tokens'][$token]);
43 $this->assertEquals(40, strlen($token)); 59 $this->assertEquals(40, strlen($token));
44 } 60 }
45 61
@@ -54,7 +70,7 @@ class SessionManagerTest extends TestCase
54 $token => 1, 70 $token => 1,
55 ], 71 ],
56 ]; 72 ];
57 $sessionManager = new SessionManager($session, self::$conf); 73 $sessionManager = new SessionManager($session, $this->conf);
58 74
59 // check and destroy the token 75 // check and destroy the token
60 $this->assertTrue($sessionManager->checkToken($token)); 76 $this->assertTrue($sessionManager->checkToken($token));
@@ -69,21 +85,18 @@ class SessionManagerTest extends TestCase
69 */ 85 */
70 public function testGenerateAndCheckToken() 86 public function testGenerateAndCheckToken()
71 { 87 {
72 $session = []; 88 $token = $this->sessionManager->generateToken();
73 $sessionManager = new SessionManager($session, self::$conf);
74
75 $token = $sessionManager->generateToken();
76 89
77 // ensure a token has been generated 90 // ensure a token has been generated
78 $this->assertEquals(1, $session['tokens'][$token]); 91 $this->assertEquals(1, $this->session['tokens'][$token]);
79 $this->assertEquals(40, strlen($token)); 92 $this->assertEquals(40, strlen($token));
80 93
81 // check and destroy the token 94 // check and destroy the token
82 $this->assertTrue($sessionManager->checkToken($token)); 95 $this->assertTrue($this->sessionManager->checkToken($token));
83 $this->assertFalse(isset($session['tokens'][$token])); 96 $this->assertFalse(isset($this->session['tokens'][$token]));
84 97
85 // ensure the token has been destroyed 98 // ensure the token has been destroyed
86 $this->assertFalse($sessionManager->checkToken($token)); 99 $this->assertFalse($this->sessionManager->checkToken($token));
87 } 100 }
88 101
89 /** 102 /**
@@ -91,10 +104,7 @@ class SessionManagerTest extends TestCase
91 */ 104 */
92 public function testCheckInvalidToken() 105 public function testCheckInvalidToken()
93 { 106 {
94 $session = []; 107 $this->assertFalse($this->sessionManager->checkToken('4dccc3a45ad9d03e5542b90c37d8db6d10f2b38b'));
95 $sessionManager = new SessionManager($session, self::$conf);
96
97 $this->assertFalse($sessionManager->checkToken('4dccc3a45ad9d03e5542b90c37d8db6d10f2b38b'));
98 } 108 }
99 109
100 /** 110 /**
@@ -146,4 +156,131 @@ class SessionManagerTest extends TestCase
146 SessionManager::checkId('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=') 156 SessionManager::checkId('c0ZqcWF3VFE2NmJBdm1HMVQ0ZHJ3UmZPbTFsNGhkNHI=')
147 ); 157 );
148 } 158 }
159
160 /**
161 * Store login information after a successful login
162 */
163 public function testStoreLoginInfo()
164 {
165 $this->sessionManager->storeLoginInfo('ip_id');
166
167 $this->assertTrue(isset($this->session['uid']));
168 $this->assertGreaterThan(time(), $this->session['expires_on']);
169 $this->assertEquals('ip_id', $this->session['ip']);
170 $this->assertEquals('johndoe', $this->session['username']);
171 }
172
173 /**
174 * Extend a server-side session by SessionManager::$SHORT_TIMEOUT
175 */
176 public function testExtendSession()
177 {
178 $this->sessionManager->extendSession();
179
180 $this->assertGreaterThan(time(), $this->session['expires_on']);
181 $this->assertLessThanOrEqual(
182 time() + SessionManager::$SHORT_TIMEOUT,
183 $this->session['expires_on']
184 );
185 }
186
187 /**
188 * Extend a server-side session by SessionManager::$LONG_TIMEOUT
189 */
190 public function testExtendSessionStaySignedIn()
191 {
192 $this->sessionManager->setStaySignedIn(true);
193 $this->sessionManager->extendSession();
194
195 $this->assertGreaterThan(time(), $this->session['expires_on']);
196 $this->assertGreaterThan(
197 time() + SessionManager::$LONG_TIMEOUT - 10,
198 $this->session['expires_on']
199 );
200 $this->assertLessThanOrEqual(
201 time() + SessionManager::$LONG_TIMEOUT,
202 $this->session['expires_on']
203 );
204 }
205
206 /**
207 * Unset session variables after logging out
208 */
209 public function testLogout()
210 {
211 $this->session = [
212 'uid' => 'some-uid',
213 'ip' => 'ip_id',
214 'expires_on' => time() + 1000,
215 'username' => 'johndoe',
216 'visibility' => 'public',
217 'untaggedonly' => false,
218 ];
219 $this->sessionManager->logout();
220
221 $this->assertFalse(isset($this->session['uid']));
222 $this->assertFalse(isset($this->session['ip']));
223 $this->assertFalse(isset($this->session['expires_on']));
224 $this->assertFalse(isset($this->session['username']));
225 $this->assertFalse(isset($this->session['visibility']));
226 $this->assertFalse(isset($this->session['untaggedonly']));
227 }
228
229 /**
230 * The session is considered as expired because the UID is missing
231 */
232 public function testHasExpiredNoUid()
233 {
234 $this->assertTrue($this->sessionManager->hasSessionExpired());
235 }
236
237 /**
238 * The session is active and expiration time has been reached
239 */
240 public function testHasExpiredTimeElapsed()
241 {
242 $this->session['uid'] = 'some-uid';
243 $this->session['expires_on'] = time() - 10;
244
245 $this->assertTrue($this->sessionManager->hasSessionExpired());
246 }
247
248 /**
249 * The session is active and expiration time has not been reached
250 */
251 public function testHasNotExpired()
252 {
253 $this->session['uid'] = 'some-uid';
254 $this->session['expires_on'] = time() + 1000;
255
256 $this->assertFalse($this->sessionManager->hasSessionExpired());
257 }
258
259 /**
260 * Session hijacking protection is disabled, we assume the IP has not changed
261 */
262 public function testHasClientIpChangedNoSessionProtection()
263 {
264 $this->conf->set('security.session_protection_disabled', true);
265
266 $this->assertFalse($this->sessionManager->hasClientIpChanged(''));
267 }
268
269 /**
270 * The client IP identifier has not changed
271 */
272 public function testHasClientIpChangedNope()
273 {
274 $this->session['ip'] = 'ip_id';
275 $this->assertFalse($this->sessionManager->hasClientIpChanged('ip_id'));
276 }
277
278 /**
279 * The client IP identifier has changed
280 */
281 public function testHasClientIpChanged()
282 {
283 $this->session['ip'] = 'ip_id_one';
284 $this->assertTrue($this->sessionManager->hasClientIpChanged('ip_id_two'));
285 }
149} 286}