Skip to content

Commit 1850d2e

Browse files
committed
Add basic auth with login page
1 parent 85086e2 commit 1850d2e

10 files changed

Lines changed: 304 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ Memcached `https://example.com/phpCacheAdmin/?dashboard=memcached&server=0&ajax&
8080
8181
Metrics are collected whenever this link is refreshed, so you can set any time in the cronjob.
8282

83+
If you have authentication enabled, set `authtoken` in `config.php` and append `&token=your-secret-token`
84+
to the cronjob URL so it can collect metrics without a login session.
85+
8386
## Environment variables
8487

8588
All keys from the [config](https://github.com/RobiNN1/phpCacheAdmin/blob/master/config.dist.php) file are supported ENV variables,

‎assets/css/styles.css‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@
204204
.mx-4 {
205205
margin-inline: calc(0.25rem * 4);
206206
}
207+
.mx-auto {
208+
margin-inline: auto;
209+
}
207210
.my-4 {
208211
margin-block: calc(0.25rem * 4);
209212
}
@@ -225,6 +228,9 @@
225228
.mb-4 {
226229
margin-bottom: calc(0.25rem * 4);
227230
}
231+
.mb-6 {
232+
margin-bottom: calc(0.25rem * 6);
233+
}
228234
.block {
229235
display: block;
230236
}
@@ -280,6 +286,9 @@
280286
.w-44 {
281287
width: calc(0.25rem * 44);
282288
}
289+
.w-48 {
290+
width: calc(0.25rem * 48);
291+
}
283292
.w-72 {
284293
width: calc(0.25rem * 72);
285294
}
@@ -292,6 +301,9 @@
292301
.max-w-lg {
293302
max-width: 32rem;
294303
}
304+
.max-w-sm {
305+
max-width: 24rem;
306+
}
295307
.max-w-xs {
296308
max-width: 20rem;
297309
}
@@ -505,9 +517,15 @@
505517
.bg-white {
506518
background-color: #fff;
507519
}
520+
.p-3 {
521+
padding: calc(0.25rem * 3);
522+
}
508523
.p-4 {
509524
padding: calc(0.25rem * 4);
510525
}
526+
.p-8 {
527+
padding: calc(0.25rem * 8);
528+
}
511529
.px-2 {
512530
padding-inline: calc(0.25rem * 2);
513531
}

‎config.dist.php‎

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -63,42 +63,12 @@
6363
//'extension' => true, // Enable Memcached extension only for get and set operations (optional).
6464
],
6565
],
66-
'apcuseparator' => ':', // Separator for tree view (optional).
67-
// Example of authentication with http auth.
68-
/*'auth' => static function (): void {
69-
$username = 'admin';
70-
$password = 'pass';
71-
72-
if (isset($_GET['logout'])) {
73-
setcookie('auth_reset', '1', time() + 60, '/');
74-
75-
$clean_uri = strtok($_SERVER['REQUEST_URI'], '?');
76-
$is_https = (
77-
(isset($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] === 1)) ||
78-
(isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
79-
);
80-
81-
header('Location: http'.($is_https ? 's' : '').'://'.$_SERVER['HTTP_HOST'].$clean_uri);
82-
exit;
83-
}
84-
85-
if (isset($_COOKIE['auth_reset'])) {
86-
setcookie('auth_reset', '', time() - 3600, '/');
87-
88-
header('WWW-Authenticate: Basic realm="phpCacheAdmin Login"');
89-
header('HTTP/1.0 401 Unauthorized');
90-
exit('You have been logged out.');
91-
}
92-
93-
if (
94-
!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) ||
95-
!hash_equals($username, $_SERVER['PHP_AUTH_USER']) || !hash_equals($password, $_SERVER['PHP_AUTH_PW'])
96-
) {
97-
header('WWW-Authenticate: Basic realm="phpCacheAdmin Login"');
98-
header('HTTP/1.0 401 Unauthorized');
99-
exit('Incorrect username or password!');
100-
}
101-
},*/
66+
'apcuseparator' => ':', // Separator for tree view (optional).
67+
'authusers' => [
68+
// Auth is enabled when at least one user is defined. Leave it commented out (or empty) to disable.
69+
//'admin' => 'your-password',
70+
],
71+
'authtoken' => 'your-secret-token', // Append &token=your-secret-token to the cronjob URL when auth is enabled.
10272
// Decoding / Encoding functions
10373
'converters' => [
10474
'gzcompress' => [

‎index.php‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,6 @@
3131
autoload($path);
3232
}
3333

34-
$auth = false;
34+
RobiNN\Pca\Auth::check();
3535

36-
if (is_callable(RobiNN\Pca\Config::get('auth'))) {
37-
RobiNN\Pca\Config::get('auth')();
38-
$auth = true;
39-
}
40-
41-
echo (new RobiNN\Pca\Admin())->render($auth);
36+
echo (new RobiNN\Pca\Admin())->render();

‎src/Admin.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ private function currentDashboard(): string {
4242
return array_key_exists($current, $this->dashboards) ? $current : array_key_first($this->dashboards);
4343
}
4444

45-
public function render(bool $auth): string {
45+
public function render(): string {
4646
$nav = array_map(static fn (DashboardInterface $d_dashboard): array => $d_dashboard->dashboardInfo(), $this->dashboards);
4747

4848
$current = $this->currentDashboard();
@@ -67,7 +67,7 @@ public function render(bool $auth): string {
6767
'colors' => $colors,
6868
'site_title' => $info['title'],
6969
'nav' => $nav,
70-
'logout_url' => $auth ? Http::queryString([], ['logout' => 'yes']) : null,
70+
'logout_url' => Auth::isEnabled() ? Http::queryString([], ['logout' => 'yes']) : null,
7171
'version' => self::VERSION,
7272
'repo' => 'https://github.com/RobiNN1/phpCacheAdmin',
7373
'dashboard' => $dashboard->dashboard(),

‎src/Auth.php‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
/**
3+
* This file is part of the phpCacheAdmin.
4+
* Copyright (c) Róbert Kelčák (https://kelcak.com/)
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace RobiNN\Pca;
10+
11+
class Auth {
12+
/**
13+
* Renders the login page and exits when the user is not authenticated.
14+
*/
15+
public static function check(): void {
16+
$users = self::users();
17+
18+
if ($users === []) {
19+
return;
20+
}
21+
22+
// Allow a cronjob to collect metrics without a login session.
23+
if (self::validToken()) {
24+
return;
25+
}
26+
27+
self::login($users);
28+
}
29+
30+
public static function isEnabled(): bool {
31+
return self::users() !== [];
32+
}
33+
34+
/**
35+
* Configured users as `username => password`, defined via the `authusers` config option.
36+
*
37+
* @return array<array-key, scalar>
38+
*/
39+
private static function users(): array {
40+
return array_filter((array) Config::get('authusers', []), static fn (mixed $password): bool => is_scalar($password));
41+
}
42+
43+
/**
44+
* @param array<array-key, scalar> $users
45+
*/
46+
private static function login(array $users): void {
47+
if (session_status() === PHP_SESSION_NONE) {
48+
session_start();
49+
}
50+
51+
if (isset($_GET['logout'])) {
52+
unset($_SESSION['pca_auth_user']);
53+
Http::redirect();
54+
}
55+
56+
$logged_user = $_SESSION['pca_auth_user'] ?? null;
57+
58+
if (is_string($logged_user) && isset($users[$logged_user])) {
59+
return; // Already logged in.
60+
}
61+
62+
$error = null;
63+
64+
if (isset($_POST['pca_login'])) {
65+
$username = (string) Http::post('username', '');
66+
67+
if (
68+
Csrf::validateToken(Http::post('csrf_token', '')) &&
69+
self::validate($users, $username, (string) Http::post('password', ''))
70+
) {
71+
session_regenerate_id(true);
72+
$_SESSION['pca_auth_user'] = $username;
73+
Http::redirect();
74+
}
75+
76+
$error = 'Incorrect username or password.';
77+
}
78+
79+
echo (new Template())->render('login', ['error' => $error]);
80+
exit;
81+
}
82+
83+
/**
84+
* @param array<array-key, scalar> $users
85+
*/
86+
public static function validate(array $users, ?string $user, ?string $password): bool {
87+
if ($user === null || $password === null || !isset($users[$user])) {
88+
return false;
89+
}
90+
91+
return hash_equals((string) $users[$user], $password);
92+
}
93+
94+
/**
95+
* A token lets the metrics cronjob bypass the login while auth is enabled.
96+
*
97+
* It only grants access to the metrics collection endpoint (?ajax&metrics), nothing else.
98+
*/
99+
private static function validToken(): bool {
100+
$token = (string) Config::get('authtoken', '');
101+
102+
if ($token === '' || !isset($_GET['ajax'], $_GET['metrics'])) {
103+
return false;
104+
}
105+
106+
return hash_equals($token, (string) ($_GET['token'] ?? ''));
107+
}
108+
}

‎src/Http.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public static function post(string $key, $default = null) {
8787
public static function redirect(array $preserve = [], array $additional = []): void {
8888
if (self::$stop_redirect === false) {
8989
$location = self::queryString($preserve, $additional);
90+
$location = $location !== '' ? $location : '?';
9091

9192
if (!headers_sent()) {
9293
header('Location: '.$location);

‎templates/layout.twig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757

5858
<div class="flex gap-4 justify-end items-center">
5959
{% if logout_url %}
60-
<a class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100" href="{{ logout_url }}" title="Logout">{{ svg('logout', 32) }}</a>
60+
<a class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100" href="{{ logout_url }}" title="Logout">{{ svg('logout', 28) }}</a>
6161
{% endif %}
6262

6363
<div class="flex border rounded-sm overflow-hidden border-gray-300 dark:border-gray-600 [&>.active]:text-gray-600 dark:[&>.active]:text-gray-300 [&>.active]:bg-gray-200 dark:[&>.active]:bg-gray-950">

‎templates/login.twig‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Login - phpCacheAdmin</title>
7+
<link rel="icon" type="image/png" sizes="32x32" href="{{ config('pcapath', '') }}assets/favicon.png">
8+
<link rel="stylesheet" href="{{ config('pcapath', '') }}assets/css/styles.css?v={{ version }}">
9+
<script>
10+
const theme = localStorage.getItem('theme') || 'system';
11+
let current_theme = theme;
12+
13+
if (theme === 'system') {
14+
current_theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
15+
}
16+
17+
document.documentElement.classList.toggle('dark', current_theme === 'dark');
18+
</script>
19+
</head>
20+
<body class="flex justify-center items-center p-4 min-h-screen text-gray-900 bg-gray-100 dark:text-gray-200 dark:bg-gray-900">
21+
<div class="p-8 w-full max-w-sm bg-white rounded-md border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
22+
<a class="block mx-auto mb-6 w-48" href="{{ config('url', '/') }}">{{ svg('logo', null) }}</a>
23+
24+
{% if error %}
25+
<div class="p-3 mb-4 text-sm text-white bg-red-500 rounded-sm">{{ error }}</div>
26+
{% endif %}
27+
28+
<form method="post" action="">
29+
<input type="hidden" name="csrf_token" value="{{ csrf }}">
30+
31+
<div class="mb-4">
32+
<label for="username" class="block mb-2 text-sm font-semibold">Username</label>
33+
<input type="text" id="username" name="username" autofocus required autocomplete="username"
34+
class="block py-2 px-4 w-full text-sm bg-white rounded-sm border border-gray-300 focus:outline-hidden focus:ring-3 focus:ring-primary-200 focus:border-primary-300 dark:text-white dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-primary-500 dark:focus:border-primary-400">
35+
</div>
36+
37+
<div class="mb-4">
38+
<label for="password" class="block mb-2 text-sm font-semibold">Password</label>
39+
<input type="password" id="password" name="password" required autocomplete="current-password"
40+
class="block py-2 px-4 w-full text-sm bg-white rounded-sm border border-gray-300 focus:outline-hidden focus:ring-3 focus:ring-primary-200 focus:border-primary-300 dark:text-white dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-primary-500 dark:focus:border-primary-400">
41+
</div>
42+
43+
<button type="submit" name="pca_login" value="1"
44+
class="py-2 px-4 w-full font-semibold text-white rounded-sm cursor-pointer bg-primary-500 hover:bg-primary-600">
45+
Sign in
46+
</button>
47+
</form>
48+
</div>
49+
</body>
50+
</html>

0 commit comments

Comments
 (0)