securing translation endpoint
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-18 13:40:06 +02:00
parent 154535edc2
commit 06d53c564e
4 changed files with 133 additions and 14 deletions

View File

@ -1959,6 +1959,58 @@ function qdb_build_app_translations_map(PDO $pdo): array {
return $result;
}
/**
* Keys safe to expose without auth (login screen + auth help only).
*/
function qdb_is_public_login_translation_key(string $key): bool {
if (str_starts_with($key, 'auth_') || str_starts_with($key, 'help_auth_')) {
return true;
}
if (str_starts_with($key, 'password_rule_')) {
return true;
}
if (preg_match('/^language_(german|english|de|en)$/i', $key) === 1) {
return true;
}
static $exact = [
'username_hint',
'password_hint',
'new_password_hint',
'confirm_password_hint',
'login_btn',
'save_password_btn',
'exit_btn',
'language',
'please_username_password',
'login_failed_with_reason',
'login_required',
'passwords_dont_match',
'password_needs_prefix',
'password_too_short',
];
return in_array($key, $exact, true);
}
/**
* Public subset of app UI strings for the login screen (auth + help_auth).
* @return array<string, array<string, string>>
*/
function qdb_build_public_login_translations_map(PDO $pdo): array {
$full = qdb_build_app_translations_map($pdo);
$filtered = [];
foreach ($full as $lang => $strings) {
if (!is_array($strings)) {
continue;
}
foreach ($strings as $key => $text) {
if (qdb_is_public_login_translation_key((string)$key)) {
$filtered[$lang][(string)$key] = (string)$text;
}
}
}
return $filtered;
}
/**
* Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
* Excludes keys from the global app UI catalog.

View File

@ -348,7 +348,11 @@ Common failures:
`GET /api/app_questionnaires?translations=1`
**Authentication:** not required. The Android app calls this on the login screen before the coach signs in. All other `app_questionnaires` routes still require `Authorization: Bearer <token>`.
**Authentication:** required (`Authorization: Bearer <token>`). Fetched during post-login sync.
`GET /api/app_questionnaires?loginTranslations=1`
**Authentication:** not required. Returns only login-screen keys (`auth_*`, `help_auth_*`, password hints/rules, and related buttons). The app fetches this on the login screen; built-in DE/EN fallbacks still apply if offline.
Success:
@ -392,15 +396,16 @@ Android should apply this map while a questionnaire is open (overlay on top of a
## Recommended Android Sync Flow
1. On app start (login screen), fetch app UI translations with `GET /api/app_questionnaires?translations=1` (no token) and cache locally.
1. On the login screen, optionally fetch `GET /api/app_questionnaires?loginTranslations=1` (no token) and cache locally; built-in DE/EN strings cover offline.
2. Login with `/api/auth/login` and store the returned token securely (EncryptedSharedPreferences on Android).
3. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh).
3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1` (Bearer token required) and cache locally.
4. Fetch the assigned client list with `GET /api/app_questionnaires?clients=1` (coach role only).
5. Fetch the active questionnaire list with `GET /api/app_questionnaires`.
6. For each list item, fetch details with `GET /api/app_questionnaires?id=<id>` (includes per-questionnaire `translations`).
7. Cache the client list, app translations, the questionnaire list, and questionnaire details (with embedded translations) locally for offline use.
8. When a questionnaire is completed, upload answers with `POST /api/app_questionnaires` using an **encrypted** JSON envelope (see above).
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details at login or app start when network is available.
There is currently no version or `updatedAt` field in the app fetch response, so the safest refresh strategy is to re-fetch translations, list, and details when syncing with the server.
## Android local data security

View File

@ -3,7 +3,8 @@
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
* GET ?translations=1 -> global app UI strings only (not questionnaire content); requires Bearer token
* GET ?loginTranslations=1 -> login + auth-help UI strings only; **no auth** (pre-login screen)
* GET ?clients=1 -> list of clients assigned to the authenticated coach
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
@ -16,16 +17,22 @@
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
*/
// Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) {
// Public login-screen catalog (auth + help_auth only; no PII).
if ($method === 'GET' && !empty($_GET['loginTranslations'])) {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
json_success([
'translations' => qdb_build_app_translations_map($pdo),
'availableLanguages' => qdb_available_language_codes($pdo),
]);
$payload = [
'translations' => qdb_build_public_login_translations_map($pdo),
'availableLanguages' => array_values(array_intersect(
qdb_available_language_codes($pdo),
['de', 'en'],
)),
];
if ($payload['availableLanguages'] === []) {
$payload['availableLanguages'] = ['de', 'en'];
}
qdb_discard($tmpDb, $lockFp);
return;
json_success($payload);
}
$tokenRec = require_valid_token();
@ -393,6 +400,15 @@ $fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
$fetchScoringReview = $_GET['scoringReview'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? '');
if ($translations) {
$payload = [
'translations' => qdb_build_app_translations_map($pdo),
'availableLanguages' => qdb_available_language_codes($pdo),
];
qdb_discard($tmpDb, $lockFp);
json_success($payload);
}
if ($fetchScoringProfiles) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';

View File

@ -85,9 +85,55 @@ final class CatalogApiTest extends QdbTestCase
$this->assertArrayHasKey('entries', $res['data']);
}
public function testAppTranslationsPublicNoAuth(): void
public function testAppTranslationsRequiresAuth(): void
{
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
$this->assertApiError($res, 'UNAUTHORIZED', 401);
}
public function testAppLoginTranslationsPublicNoAuth(): void
{
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['loginTranslations' => '1']);
$this->assertApiOk($res);
$this->assertArrayHasKey('translations', $res['data']);
$map = $res['data']['translations'];
$this->assertIsArray($map);
// Must include auth / help_auth keys and must not dump unrelated catalog keys.
$foundAuth = false;
$foundHelp = false;
$forbidden = false;
foreach ($map as $lang => $strings) {
$this->assertIsArray($strings);
foreach ($strings as $key => $text) {
$this->assertTrue(
qdb_is_public_login_translation_key((string)$key),
"Unexpected public key: {$key}"
);
if (str_starts_with((string)$key, 'auth_')) {
$foundAuth = true;
}
if (str_starts_with((string)$key, 'help_auth_')) {
$foundHelp = true;
}
if ((string)$key === 'client_code' || (string)$key === 'client_picker_title') {
$forbidden = true;
}
}
}
$this->assertTrue($foundAuth);
$this->assertTrue($foundHelp);
$this->assertFalse($forbidden);
}
public function testAppTranslationsWithToken(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'translations' => '1',
]);
$this->assertApiOk($res);
$this->assertArrayHasKey('translations', $res['data']);
}