revoked access to website for coaches, added web-specific tokens

This commit is contained in:
2026-05-26 15:49:19 +02:00
parent 7671c9f329
commit 2181d1731e
36 changed files with 129 additions and 41 deletions

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
switch ($method) { switch ($method) {

View File

@ -4,8 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role']; $callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? ''; $callerEntityID = $tokenRec['entityID'] ?? '';

View File

@ -2,7 +2,7 @@
require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php'; require_once __DIR__ . '/../db_init.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');

View File

@ -11,7 +11,7 @@ require_once __DIR__ . '/../lib/validate.php';
// CORS // CORS
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-QDB-Client');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); http_response_code(204);

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
switch ($method) { switch ($method) {

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
switch ($method) { switch ($method) {

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405); http_response_code(405);

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
$validTypes = ['question', 'answer_option', 'string', 'language']; $validTypes = ['question', 'answer_option', 'string', 'language'];

View File

@ -4,8 +4,7 @@ require_once __DIR__ . '/../db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role']; $callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? ''; $callerEntityID = $tokenRec['entityID'] ?? '';

View File

@ -5,8 +5,7 @@
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$role = $tokenRec['role']; $role = $tokenRec['role'];
$entityID = $tokenRec['entityID'] ?? ''; $entityID = $tokenRec['entityID'] ?? '';

View File

@ -4,7 +4,7 @@
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
$dbPath = QDB_PATH; $dbPath = QDB_PATH;

View File

@ -65,6 +65,16 @@ try {
exit; exit;
} }
if (($user['role'] ?? '') === 'coach') {
qdb_discard($tmpDb, $lockFp);
http_response_code(403);
echo json_encode([
"success" => false,
"message" => "Coach accounts can only sign in through the mobile app.",
]);
exit;
}
if (!password_verify($oldPassword, $user['passwordHash'])) { if (!password_verify($oldPassword, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
http_response_code(401); http_response_code(401);

View File

@ -178,6 +178,35 @@ function require_role(array $allowed, array $tokenRecord): void {
} }
} }
/** Roles that may use the management website (not the mobile app). */
function qdb_web_roles(): array {
return ['admin', 'supervisor'];
}
/** True when the request originates from the management website (see website/js/api.js). */
function qdb_is_web_client_request(): bool {
$h = $_SERVER['HTTP_X_QDB_CLIENT'] ?? '';
return strtolower(trim($h)) === 'web';
}
/** Reject coach sign-in from the website; coaches use the mobile app only. */
function qdb_reject_coach_web_login(string $role): void {
if ($role === 'coach' && qdb_is_web_client_request()) {
json_error(
'FORBIDDEN',
'Coach accounts can only sign in through the mobile app.',
403
);
}
}
/** Valid bearer token plus admin/supervisor role (website API). */
function require_valid_token_web(): array {
$rec = require_valid_token();
require_role(qdb_web_roles(), $rec);
return $rec;
}
/** /**
* Build a SQL WHERE clause fragment that restricts client visibility by role. * Build a SQL WHERE clause fragment that restricts client visibility by role.
* Returns [string $clause, array $params]. * Returns [string $clause, array $params].

View File

@ -2,7 +2,7 @@
// /var/www/html/db_download.php // /var/www/html/db_download.php
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$dbPath = QDB_PATH; $dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; } if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }

View File

@ -3,7 +3,7 @@
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$dbPath = QDB_PATH; $dbPath = QDB_PATH;
if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; } if (!file_exists($dbPath)) { http_response_code(404); echo "Database not found"; exit; }

View File

@ -3,7 +3,7 @@
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$ORDER_JSON = __DIR__ . '/header_order.json'; $ORDER_JSON = __DIR__ . '/header_order.json';
$LABELS_JSON = __DIR__ . '/questions_en.json'; $LABELS_JSON = __DIR__ . '/questions_en.json';

View File

@ -2,7 +2,7 @@
// /var/www/html/downloadFull.php // /var/www/html/downloadFull.php
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$token = $tokenRec['_token']; $token = $tokenRec['_token'];
$dbPath = QDB_PATH; $dbPath = QDB_PATH;

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
switch ($method) { switch ($method) {

View File

@ -1,7 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role']; $callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? ''; $callerEntityID = $tokenRec['entityID'] ?? '';

View File

@ -43,6 +43,8 @@ case 'auth/login':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
if ((int)$user['mustChangePassword'] === 1) { if ((int)$user['mustChangePassword'] === 1) {
$tempToken = bin2hex(random_bytes(32)); $tempToken = bin2hex(random_bytes(32));
token_add($tempToken, 10 * 60, [ token_add($tempToken, 10 * 60, [
@ -127,6 +129,8 @@ case 'auth/change-password':
json_error('FORBIDDEN', 'Token does not match user', 403); json_error('FORBIDDEN', 'Token does not match user', 403);
} }
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
if (!password_verify($oldPassword, $user['passwordHash'])) { if (!password_verify($oldPassword, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401); json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401);

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
$dbPath = QDB_PATH; $dbPath = QDB_PATH;

View File

@ -1,7 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role']; $callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? ''; $callerEntityID = $tokenRec['entityID'] ?? '';

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$token = $tokenRec['_token']; $token = $tokenRec['_token'];
if ($method !== 'GET') { if ($method !== 'GET') {

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
if ($method !== 'GET') { if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
switch ($method) { switch ($method) {

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
switch ($method) { switch ($method) {

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
if ($method !== 'GET') { if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);

View File

@ -1,6 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language']; $validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];

View File

@ -1,7 +1,6 @@
<?php <?php
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
$callerRole = $tokenRec['role']; $callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? ''; $callerEntityID = $tokenRec['entityID'] ?? '';

View File

@ -11,7 +11,7 @@ $isCli = (php_sapi_name() === 'cli');
if (!$isCli) { if (!$isCli) {
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
} }

View File

@ -233,6 +233,11 @@ async function login(evt) {
return; return;
} }
if (data.role === 'coach') {
$('#loginMsg').textContent = 'Coach accounts can only sign in through the mobile app.';
return;
}
if (data.must_change_password) { if (data.must_change_password) {
_pendingUser = data.user; _pendingUser = data.user;
$('#loginCard').classList.add('hidden'); $('#loginCard').classList.add('hidden');

View File

@ -38,6 +38,15 @@ try {
exit; exit;
} }
if (($user['role'] ?? '') === 'coach') {
http_response_code(403);
echo json_encode([
"success" => false,
"message" => "Coach accounts can only sign in through the mobile app.",
]);
exit;
}
if ((int)$user['mustChangePassword'] === 1) { if ((int)$user['mustChangePassword'] === 1) {
$tempToken = bin2hex(random_bytes(32)); $tempToken = bin2hex(random_bytes(32));
token_add($tempToken, 10 * 60, [ token_add($tempToken, 10 * 60, [

View File

@ -9,7 +9,7 @@
require_once __DIR__ . '/db_init.php'; require_once __DIR__ . '/db_init.php';
header('Content-Type: application/json; charset=UTF-8'); header('Content-Type: application/json; charset=UTF-8');
$tokenRec = require_valid_token(); $tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec); require_role(['admin'], $tokenRec);
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@ -7,6 +7,7 @@ function getToken() {
async function apiFetch(endpoint, options = {}) { async function apiFetch(endpoint, options = {}) {
const token = getToken(); const token = getToken();
const headers = options.headers || {}; const headers = options.headers || {};
headers['X-QDB-Client'] = 'web';
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
if (!(options.body instanceof FormData)) { if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json; charset=UTF-8'; headers['Content-Type'] = 'application/json; charset=UTF-8';

View File

@ -19,9 +19,26 @@ export function getRole() {
export function getUser() { export function getUser() {
return localStorage.getItem('qdb_user') || ''; return localStorage.getItem('qdb_user') || '';
} }
const WEBSITE_ROLES = ['admin', 'supervisor'];
export function canAccessWebsite() {
return WEBSITE_ROLES.includes(getRole());
}
export function canEdit() { export function canEdit() {
const r = getRole(); return canAccessWebsite();
return r === 'admin' || r === 'supervisor'; }
function clearSession() {
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
}
function rejectCoachSession() {
clearSession();
showToast('Coach accounts must use the mobile app.', 'error');
navigate('#/login');
} }
export function showToast(message, type = 'info') { export function showToast(message, type = 'info') {
@ -40,6 +57,7 @@ export function showToast(message, type = 'info') {
function authGuard(handler) { function authGuard(handler) {
return (params) => { return (params) => {
if (!isLoggedIn()) { navigate('#/login'); return; } if (!isLoggedIn()) { navigate('#/login'); return; }
if (!canAccessWebsite()) { rejectCoachSession(); return; }
return handler(params); return handler(params);
}; };
} }
@ -95,12 +113,14 @@ addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('logoutBtn').addEventListener('click', (e) => { document.getElementById('logoutBtn').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
localStorage.removeItem('qdb_token'); clearSession();
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
navigate('#/login'); navigate('#/login');
}); });
if (isLoggedIn() && !canAccessWebsite()) {
clearSession();
}
window.addEventListener('hashchange', updateNav); window.addEventListener('hashchange', updateNav);
updateNav(); updateNav();
startRouter(); startRouter();

View File

@ -55,7 +55,10 @@ export function loginPage() {
try { try {
const res = await fetch('../api/auth/login', { const res = await fetch('../api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'X-QDB-Client': 'web',
},
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
const json = await res.json(); const json = await res.json();
@ -65,6 +68,11 @@ export function loginPage() {
return; return;
} }
const d = json.data; const d = json.data;
if (d.role === 'coach') {
errEl.textContent = 'Coach accounts can only sign in through the mobile app.';
errEl.style.display = '';
return;
}
if (d.mustChangePassword) { if (d.mustChangePassword) {
pendingUsername = username; pendingUsername = username;
pendingTempToken = d.token; pendingTempToken = d.token;
@ -95,7 +103,10 @@ export function loginPage() {
} }
const oldPw = document.getElementById('password').value; const oldPw = document.getElementById('password').value;
try { try {
const headers = { 'Content-Type': 'application/json' }; const headers = {
'Content-Type': 'application/json',
'X-QDB-Client': 'web',
};
if (pendingTempToken) { if (pendingTempToken) {
headers['Authorization'] = `Bearer ${pendingTempToken}`; headers['Authorization'] = `Bearer ${pendingTempToken}`;
} }
@ -111,6 +122,11 @@ export function loginPage() {
return; return;
} }
const d = json.data; const d = json.data;
if (d.role === 'coach') {
errEl.textContent = 'Coach accounts can only sign in through the mobile app.';
errEl.style.display = '';
return;
}
localStorage.setItem('qdb_token', d.token); localStorage.setItem('qdb_token', d.token);
localStorage.setItem('qdb_user', d.user); localStorage.setItem('qdb_user', d.user);
localStorage.setItem('qdb_role', d.role); localStorage.setItem('qdb_role', d.role);