Skip to content

Commit cf74581

Browse files
committed
Rework Authentication into separate services
This is a from-scratch rewrite, moving a bit closer to Single Responsibility Principle. We split handling of credentials-in-config and always-open authentication systems. In the future, we will be able implement more methods this way. This was motivated by session code being called in constructor, which would break in CLI with Tracy strict mode. For now, we are just porting the Authentication helper and controller. Additionally: - Session verification now also checks if the credentials in the config did not change. - Requests from loopback IP address now give full access to all operations, not just update. - IPv6 loopback address is recognized as well. - Requests forwarded by proxies are filtered out since local reverse proxies might come from loopback as well. One thing I do not like that any request with credentials will automatically persist the login to session but removing that feature can be done later.
1 parent 9f117a0 commit cf74581

File tree

11 files changed

+299
-107
lines changed

11 files changed

+299
-107
lines changed

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
- Source filters are stricter, they need to start and end with a `/`. ([#1423](https://github.com/fossar/selfoss/pull/1423))
4141
- OPML importer has been merged into the React client. ([#1442](https://github.com/fossar/selfoss/pull/1442))
4242
- Web requests will send `Accept-Encoding` header. ([#1482](https://github.com/fossar/selfoss/pull/1482))
43+
- Authentication system has been rewritten to allow more methods in the future. ([#1491](https://github.com/fossar/selfoss/pull/1491))
44+
- Authentication will now also log user out when the credentials in the config change. ([#1491](https://github.com/fossar/selfoss/pull/1491))
45+
- Requests from loopback IP address now give full access to all operations, not just update. Additionally, IPv6 loopback address is recognized and proxies are ignored. ([#1491](https://github.com/fossar/selfoss/pull/1491))
4346

4447
#### For developers
4548
- Back-end source code is now checked using [PHPStan](https://phpstan.org/). ([#1409](https://github.com/fossar/selfoss/pull/1409))

src/common.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ function boot_error(string $message) {
6969
->register(helpers\Authentication::class)
7070
->setShared(true)
7171
;
72+
73+
$container
74+
->register(
75+
helpers\Authentication\AuthenticationService::class,
76+
[new Slince\Di\Reference(helpers\Authentication\AuthenticationFactory::class), 'create']
77+
)
78+
->setShared(true)
79+
;
80+
7281
$container
7382
->register(helpers\Session::class)
7483
->setShared(true)

src/controllers/About.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function about(): void {
5454
'htmlTitle' => trim($this->configuration->htmlTitle), // string
5555
'allowPublicUpdate' => $this->configuration->allowPublicUpdateAccess, // bool
5656
'publicMode' => $this->configuration->public, // bool
57-
'authEnabled' => $this->authentication->enabled() === true, // bool
57+
'authEnabled' => $this->authentication->enabled(), // bool
5858
'readingSpeed' => $this->configuration->readingSpeedWpm > 0 ? $this->configuration->readingSpeedWpm : null, // ?int
5959
'language' => $this->configuration->language === '0' ? null : $this->configuration->language, // ?string
6060
'userCss' => file_exists(BASEDIR . '/user.css') ? filemtime(BASEDIR . '/user.css') : null, // ?int

src/controllers/Authentication.php

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@
44

55
namespace controllers;
66

7-
use helpers;
7+
use helpers\Authentication\AuthenticationService;
88
use helpers\View;
99

1010
/**
1111
* Controller for user related tasks
1212
*/
1313
class Authentication {
14-
private helpers\Authentication $authentication;
14+
private AuthenticationService $authenticationService;
1515
private View $view;
1616

17-
public function __construct(helpers\Authentication $authentication, View $view) {
18-
$this->authentication = $authentication;
17+
public function __construct(AuthenticationService $authenticationService, View $view) {
18+
$this->authenticationService = $authenticationService;
1919
$this->view = $view;
2020
}
2121

@@ -26,17 +26,11 @@ public function __construct(helpers\Authentication $authentication, View $view)
2626
public function login(): void {
2727
$error = null;
2828

29-
if (isset($_REQUEST['username'])) {
30-
$username = $_REQUEST['username'];
31-
} else {
32-
$username = '';
29+
if (!isset($_REQUEST['username'])) {
3330
$error = 'no username given';
3431
}
3532

36-
if (isset($_REQUEST['password'])) {
37-
$password = $_REQUEST['password'];
38-
} else {
39-
$password = '';
33+
if (!isset($_REQUEST['password'])) {
4034
$error = 'no password given';
4135
}
4236

@@ -47,7 +41,8 @@ public function login(): void {
4741
]);
4842
}
4943

50-
if ($this->authentication->login($username, $password)) {
44+
// The function automatically checks the request for credentials.
45+
if ($this->authenticationService->isPrivileged()) {
5146
$this->view->jsonSuccess([
5247
'success' => true,
5348
]);
@@ -64,7 +59,7 @@ public function login(): void {
6459
* json
6560
*/
6661
public function logout(): void {
67-
$this->authentication->logout();
62+
$this->authenticationService->destroy();
6863
$this->view->jsonSuccess([
6964
'success' => true,
7065
]);

src/helpers/Authentication.php

Lines changed: 13 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
namespace helpers;
1414

15-
use Monolog\Logger;
15+
use helpers\Authentication\AuthenticationService;
1616

1717
/**
1818
* Helper class for user authentication.
@@ -24,106 +24,31 @@
2424
* - **Privileged**: Any other operation (admin) user, full access without any limitations.
2525
*/
2626
class Authentication {
27-
private bool $loggedin = false;
27+
private AuthenticationService $authenticationService;
2828

29-
private Configuration $configuration;
30-
private Logger $logger;
31-
private Session $session;
32-
33-
/**
34-
* start session and check login
35-
*/
36-
public function __construct(Configuration $configuration, Logger $logger, Session $session) {
37-
$this->configuration = $configuration;
38-
$this->logger = $logger;
39-
$this->session = $session;
40-
41-
if ($this->enabled() === false) {
42-
return;
43-
}
44-
45-
if ($this->session->getBool('loggedin', false)) {
46-
$this->loggedin = true;
47-
$this->logger->debug('logged in using valid session');
48-
} else {
49-
$this->logger->debug('session does not contain valid auth');
50-
}
51-
52-
// autologin if request contains unsername and password
53-
if ($this->loggedin === false
54-
&& isset($_REQUEST['username'])
55-
&& isset($_REQUEST['password'])) {
56-
$this->login($_REQUEST['username'], $_REQUEST['password']);
57-
}
29+
public function __construct(AuthenticationService $authenticationService) {
30+
$this->authenticationService = $authenticationService;
5831
}
5932

6033
/**
6134
* login enabled
6235
*/
6336
public function enabled(): bool {
64-
return strlen($this->configuration->username) != 0 && strlen($this->configuration->password) != 0;
65-
}
66-
67-
/**
68-
* login user
69-
*/
70-
public function login(string $username, string $password): bool {
71-
if ($this->enabled()) {
72-
$usernameCorrect = $username === $this->configuration->username;
73-
$hashedPassword = $this->configuration->password;
74-
// Passwords hashed with password_hash start with $, otherwise use the legacy path.
75-
$passwordCorrect =
76-
$hashedPassword !== '' && $hashedPassword[0] === '$'
77-
? password_verify($password, $hashedPassword)
78-
: hash('sha512', $this->configuration->salt . $password) === $hashedPassword;
79-
$credentialsCorrect = $usernameCorrect && $passwordCorrect;
80-
81-
if ($credentialsCorrect) {
82-
$this->loggedin = true;
83-
$this->session->setBool('loggedin', true);
84-
$this->logger->debug('logged in with supplied username and password');
85-
86-
return true;
87-
} else {
88-
$this->logger->debug('failed to log in with supplied username and password');
89-
90-
return false;
91-
}
92-
}
93-
94-
return true;
95-
}
96-
97-
private function isPrivileged(): bool {
98-
if ($this->enabled() === false) {
99-
return true;
100-
}
101-
102-
return $this->loggedin;
37+
return !$this->authenticationService instanceof Authentication\Services\Trust;
10338
}
10439

10540
/**
10641
* showPrivateTags
10742
*/
10843
public function showPrivateTags(): bool {
109-
return $this->isPrivileged();
110-
}
111-
112-
/**
113-
* logout
114-
*/
115-
public function logout(): void {
116-
$this->loggedin = false;
117-
$this->session->setBool('loggedin', false);
118-
$this->session->destroy();
119-
$this->logger->debug('logged out and destroyed session');
44+
return $this->authenticationService->isPrivileged();
12045
}
12146

12247
/**
12348
* If user is not authorized to read, force them to authenticate.
12449
*/
12550
public function ensureCanRead(): void {
126-
if ($this->isPrivileged() !== true && !$this->configuration->public) {
51+
if (!$this->authenticationService->canRead()) {
12752
$this->forbidden();
12853
}
12954
}
@@ -132,16 +57,19 @@ public function ensureCanRead(): void {
13257
* If user is not authorized to privileged operations, force them to authenticate.
13358
*/
13459
public function ensureIsPrivileged(): void {
135-
if ($this->isPrivileged() !== true) {
60+
if (!$this->authenticationService->isPrivileged()) {
13661
$this->forbidden();
13762
}
13863
}
13964

14065
/**
14166
* send 403 if not logged in
67+
*
68+
* @return never
14269
*/
143-
public function forbidden(): void {
70+
private function forbidden(): void {
14471
header('HTTP/1.0 403 Forbidden');
72+
header('Content-type: text/plain');
14573
echo 'Access forbidden!';
14674
exit;
14775
}
@@ -154,9 +82,6 @@ public function forbidden(): void {
15482
* or public update must be allowed in the config.
15583
*/
15684
public function allowedToUpdate(): bool {
157-
return $this->isPrivileged() === true
158-
|| $_SERVER['REMOTE_ADDR'] === $_SERVER['SERVER_ADDR']
159-
|| $_SERVER['REMOTE_ADDR'] === '127.0.0.1'
160-
|| $this->configuration->allowPublicUpdateAccess;
85+
return $this->authenticationService->canUpdate();
16186
}
16287
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2024 Jan Tojnar <[email protected]>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace helpers\Authentication;
9+
10+
use helpers\Configuration;
11+
use Psr\Container\ContainerInterface;
12+
13+
/**
14+
* Factory that creates `AuthenticationService` based on the configuration.
15+
*/
16+
final class AuthenticationFactory {
17+
private Configuration $configuration;
18+
private ContainerInterface $container;
19+
20+
public function __construct(Configuration $configuration, ContainerInterface $container) {
21+
$this->configuration = $configuration;
22+
$this->container = $container;
23+
}
24+
25+
public function create(): AuthenticationService {
26+
if (!$this->useCredentials() || $this->isCli() || $this->isLocalIp()) {
27+
return $this->container->get(Services\Trust::class);
28+
}
29+
30+
return $this->container->get(Services\RequestOrSession::class);
31+
}
32+
33+
private function isCli(): bool {
34+
return PHP_SAPI === 'cli';
35+
}
36+
37+
private function isLocalIp(): bool {
38+
// We cannot trust these IP addresses but we know they are likely not local.
39+
if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) || isset($_SERVER['HTTP_FORWARDED'])) {
40+
return false;
41+
}
42+
43+
return $_SERVER['REMOTE_ADDR'] === '::1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1';
44+
}
45+
46+
private function useCredentials(): bool {
47+
return strlen($this->configuration->username) > 0 && strlen($this->configuration->password) > 0;
48+
}
49+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2024 Jan Tojnar <[email protected]>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace helpers\Authentication;
9+
10+
/**
11+
* Must be implemented by any authentication service.
12+
*/
13+
interface AuthenticationService {
14+
/**
15+
* Checks whether user is authorized to read (logged in/public mode).
16+
*/
17+
public function canRead(): bool;
18+
19+
/**
20+
* Checks whether user is authorized to update sources (logged in/public update mode).
21+
*/
22+
public function canUpdate(): bool;
23+
24+
/**
25+
* Checks whether user is authorized to perform a privileged action
26+
* or access privileged information.
27+
*/
28+
public function isPrivileged(): bool;
29+
30+
/**
31+
* Give up authorization.
32+
*/
33+
public function destroy(): void;
34+
}

0 commit comments

Comments
 (0)