190 lines · 6.5 KB
Raw Download
1
<?php
2
require_once __DIR__ . '/db.php';
3
4
// Name of the "stay logged in" cookie, and how long a login lasts without
5
// being used. A year is effectively "until you log out" for a tool you open
6
// every few days, while still expiring a cookie left on a borrowed phone.
7
const REMEMBER_COOKIE = 'AUTOFEEDAUTH';
8
const REMEMBER_DAYS   = 365;
9
10
// Whether the request arrived over TLS, so cookies can be marked Secure and
11
// the login page can tell whether passkeys are even possible. Checks the
12
// forwarded header too: LiteSpeed behind a proxy terminates TLS upstream and
13
// leaves $_SERVER['HTTPS'] unset.
14
function is_https(): bool
15
{
16
    return (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off')
17
        || strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
18
        || (int)($_SERVER['SERVER_PORT'] ?? 0) === 443;
19
}
20
21
// Both cookies this app sets share these flags. Lax (not Strict) so that
22
// following a link into the app from elsewhere still arrives logged in.
23
function cookie_opts(int $expires): array
24
{
25
    return [
26
        'expires'  => $expires,
27
        'path'     => '/',
28
        'httponly' => true,
29
        'samesite' => 'Lax',
30
        'secure'   => is_https(),
31
    ];
32
}
33
34
// Logins used to lapse constantly for two reasons, both fixed here: the
35
// session cookie had lifetime 0, so it died whenever the browser closed (a
36
// phone backgrounding a tab is enough), and PHP sweeps idle session data
37
// after gc_maxlifetime — 24 minutes by default, and on shared hosting another
38
// site's garbage collection can take this app's session files with it.
39
// The remember-me cookie below is the real backstop: even if the server-side
40
// session is swept, the next request silently re-establishes it.
41
ini_set('session.gc_maxlifetime', (string)(REMEMBER_DAYS * 86400));
42
ini_set('session.use_strict_mode', '1'); // refuse session ids the server never issued
43
session_set_cookie_params([
44
    'lifetime' => REMEMBER_DAYS * 86400, // session_set_cookie_params takes a duration, not an expiry stamp
45
    'path'     => '/',
46
    'httponly' => true,
47
    'samesite' => 'Lax',
48
    'secure'   => is_https(),
49
]);
50
session_name('AUTOFEEDSESS');
51
session_start();
52
53
function is_logged_in(): bool
54
{
55
    return !empty($_SESSION['auth']);
56
}
57
58
// Start an authenticated session. Regenerating the id on every login is what
59
// stops a session id captured before login from being valid after it.
60
function log_in(): void
61
{
62
    session_regenerate_id(true);
63
    $_SESSION['auth'] = true;
64
}
65
66
// Issue a "stay logged in" cookie using the selector/validator pattern: the
67
// cookie carries a public selector plus a secret validator, and only a hash
68
// of the validator is stored. A leaked database therefore cannot be turned
69
// back into a login, and the selector keeps the lookup a single indexed query
70
// rather than a scan comparing every row.
71
function remember_login(): void
72
{
73
    $selector  = bin2hex(random_bytes(9));
74
    $validator = bin2hex(random_bytes(32));
75
    db()->prepare("INSERT INTO auth_tokens (selector, validator, last_used_at) VALUES (?,?,datetime('now'))")
76
        ->execute([$selector, hash('sha256', $validator)]);
77
    setcookie(REMEMBER_COOKIE, $selector . ':' . $validator, cookie_opts(time() + REMEMBER_DAYS * 86400));
78
}
79
80
// Re-establish a session from the remember-me cookie. Runs on any request
81
// that arrives without one, which is exactly the case that used to bounce
82
// you to the login form.
83
function try_remember_login(): void
84
{
85
    $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
86
    if ($raw === '' || substr_count($raw, ':') !== 1) {
87
        return;
88
    }
89
    [$selector, $validator] = explode(':', $raw, 2);
90
    $st = db()->prepare('SELECT * FROM auth_tokens WHERE selector = ?');
91
    $st->execute([$selector]);
92
    $row = $st->fetch();
93
    // hash_equals so a real selector with a wrong validator cannot be told
94
    // apart, by timing, from a selector that was never issued.
95
    if (!$row || !hash_equals((string)$row['validator'], hash('sha256', $validator))) {
96
        return;
97
    }
98
    if (strtotime($row['created_at'] . ' UTC') < time() - REMEMBER_DAYS * 86400) {
99
        db()->prepare('DELETE FROM auth_tokens WHERE id = ?')->execute([$row['id']]);
100
        return;
101
    }
102
    log_in();
103
    db()->prepare("UPDATE auth_tokens SET last_used_at = datetime('now') WHERE id = ?")->execute([$row['id']]);
104
}
105
106
// Tear a login down completely: server-side session data, the remember-me
107
// row, and both cookies in the browser. Dropping the row matters most — a
108
// token left behind would stay valid for anyone still holding the cookie.
109
function log_out(): void
110
{
111
    $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
112
    if ($raw !== '' && substr_count($raw, ':') === 1) {
113
        db()->prepare('DELETE FROM auth_tokens WHERE selector = ?')->execute([explode(':', $raw, 2)[0]]);
114
    }
115
    $_SESSION = [];
116
    $kill = cookie_opts(time() - 42000);
117
    setcookie(REMEMBER_COOKIE, '', $kill);
118
    if (ini_get('session.use_cookies')) {
119
        setcookie(session_name(), '', $kill);
120
    }
121
    unset($_COOKIE[REMEMBER_COOKIE], $_COOKIE[session_name()]);
122
    session_destroy();
123
}
124
125
// A logged-out request carrying a remember-me cookie is the common case on a
126
// phone that closed the tab days ago, so the check runs before anything asks
127
// whether we are logged in. No cookie means no query, so public visitors and
128
// the logged-out login page cost nothing.
129
if (!is_logged_in()) {
130
    try_remember_login();
131
}
132
133
function require_login(): void
134
{
135
    if (!is_logged_in()) {
136
        header('Location: login.php');
137
        exit;
138
    }
139
}
140
141
function csrf_token(): string
142
{
143
    return $_SESSION['csrf'] ??= bin2hex(random_bytes(16));
144
}
145
146
function csrf_field(): string
147
{
148
    return '<input type="hidden" name="csrf" value="' . csrf_token() . '">';
149
}
150
151
function check_csrf(): void
152
{
153
    if (!hash_equals(csrf_token(), (string)($_POST['csrf'] ?? ''))) {
154
        http_response_code(400);
155
        exit('Bad request (missing or invalid CSRF token).');
156
    }
157
}
158
159
function password_is_set(): bool
160
{
161
    return is_file(config()['pass_file']);
162
}
163
164
function verify_password(string $pass): bool
165
{
166
    $hash = trim((string)@file_get_contents(config()['pass_file']));
167
    return $hash !== '' && password_verify($pass, $hash);
168
}
169
170
function save_password(string $pass): void
171
{
172
    $file = config()['pass_file'];
173
    $dir = dirname($file);
174
    if (!is_dir($dir)) {
175
        mkdir($dir, 0775, true);
176
    }
177
    file_put_contents($file, password_hash($pass, PASSWORD_DEFAULT));
178
}
179
180
function flash(?string $msg = null): ?string
181
{
182
    if ($msg !== null) {
183
        $_SESSION['flash'] = $msg;
184
        return null;
185
    }
186
    $m = $_SESSION['flash'] ?? null;
187
    unset($_SESSION['flash']);
188
    return $m;
189
}
190