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; 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). * Flat language -> stringKey -> text map for one questionnaire (questions, options, config strings).
* Excludes keys from the global app UI catalog. * Excludes keys from the global app UI catalog.

View File

@ -348,7 +348,11 @@ Common failures:
`GET /api/app_questionnaires?translations=1` `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: Success:
@ -392,15 +396,16 @@ Android should apply this map while a questionnaire is open (overlay on top of a
## Recommended Android Sync Flow ## 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). 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). 3. Fetch app UI translations with `GET /api/app_questionnaires?translations=1` (Bearer token required) and cache locally.
4. Re-fetch app UI translations optionally after login (same public endpoint, or authenticated refresh). 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`). 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. 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). 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 ## Android local data security

View File

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

View File

@ -85,9 +85,55 @@ final class CatalogApiTest extends QdbTestCase
$this->assertArrayHasKey('entries', $res['data']); $this->assertArrayHasKey('entries', $res['data']);
} }
public function testAppTranslationsPublicNoAuth(): void public function testAppTranslationsRequiresAuth(): void
{ {
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']); $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->assertApiOk($res);
$this->assertArrayHasKey('translations', $res['data']); $this->assertArrayHasKey('translations', $res['data']);
} }