diff --git a/.env.example b/.env.example index d339c16..31cc869 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,11 @@ ADMIN_EMAIL=admin@polotsk-transit.local # Frontend API_URL=/api/v1 +# Required by the passenger telemetry endpoint to verify Firebase ID tokens. +# Use the same Firebase project as the mobile app; keep the service account JSON outside the repo. +FIREBASE_PROJECT_ID=your-firebase-project-id +GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json + # Production settings (uncomment for production) # NODE_ENV=production # CORS_ORIGIN=https://admin.yourdomain.com diff --git a/README.md b/README.md index 5557165..fa72588 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,13 @@ ADMIN_PASSWORD=your_secure_admin_password_here CORS_ORIGIN=http://localhost:3001 API_URL=/api/v1 -# Push отключён по умолчанию и не требует Firebase credentials +# Push отключён по умолчанию FCM_ENABLED=false + +# Для пассажирского режима backend проверяет Firebase ID token. +# Укажите проект из google-services.json мобильного приложения и путь к service account JSON. +FIREBASE_PROJECT_ID=your-firebase-project-id +GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.json ``` Для входа по passkey на публичном адресе укажите в `.env` точный домен панели diff --git a/backend/src/config/firebase.js b/backend/src/config/firebase.js index ec4af36..6f521cd 100644 --- a/backend/src/config/firebase.js +++ b/backend/src/config/firebase.js @@ -24,6 +24,18 @@ function getFirebaseConfig(env = process.env) { let messagingSingleton; +function getFirebaseApp(config = getFirebaseConfig()) { + // Keep Firebase Admin lazy: deployments that do not receive passenger telemetry + // still run without local ADC credentials. + const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app'); + + const existingApp = getApps()[0]; + return existingApp || initializeApp({ + credential: applicationDefault(), + ...(config.projectId ? { projectId: config.projectId } : {}) + }); +} + function createFirebaseMessaging(config = getFirebaseConfig()) { if (!config.enabled) { return null; @@ -31,16 +43,8 @@ function createFirebaseMessaging(config = getFirebaseConfig()) { // Keep Firebase optional for disabled/local environments. These modular // imports are evaluated only when an enabled notification is actually sent. - const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app'); const { getMessaging } = require('firebase-admin/messaging'); - - const existingApp = getApps()[0]; - const app = existingApp || initializeApp({ - credential: applicationDefault(), - ...(config.projectId ? { projectId: config.projectId } : {}) - }); - - return getMessaging(app); + return getMessaging(getFirebaseApp(config)); } function getFirebaseMessaging(config = getFirebaseConfig()) { @@ -55,11 +59,18 @@ function getFirebaseMessaging(config = getFirebaseConfig()) { return messagingSingleton; } +function createFirebaseAuth(config = getFirebaseConfig()) { + const { getAuth } = require('firebase-admin/auth'); + return getAuth(getFirebaseApp(config)); +} + module.exports = { DEFAULT_FCM_TOPIC, DEFAULT_FCM_ANDROID_CHANNEL_ID, parseBoolean, getFirebaseConfig, + getFirebaseApp, + createFirebaseAuth, createFirebaseMessaging, getFirebaseMessaging }; diff --git a/backend/src/index.js b/backend/src/index.js index 24a7765..d7c05ff 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -77,9 +77,15 @@ try { logger.warn('Upload routes disabled - multer not installed. Run: npm install multer'); } -// API routes with API key authentication and rate limiting +// Passenger telemetry has its own Firebase ID-token authentication inside apiRoutes. +// All other API routes keep the existing API-key/custom-JWT contract. app.use('/api/v1', - authenticateApiKey, + (req, res, next) => { + if (req.path === '/passenger-telemetry/ingest') { + return next(); + } + return authenticateApiKey(req, res, next); + }, createRateLimiter(1000, 3600000), // 1000 requests per hour apiRoutes ); diff --git a/backend/src/middleware/passengerTelemetryAuth.js b/backend/src/middleware/passengerTelemetryAuth.js new file mode 100644 index 0000000..173f784 --- /dev/null +++ b/backend/src/middleware/passengerTelemetryAuth.js @@ -0,0 +1,53 @@ +const logger = require('../config/logger'); +const { createFirebaseAuth } = require('../config/firebase'); + +function getBearerToken(req) { + const authorization = typeof req.get === 'function' + ? req.get('Authorization') + : req.headers?.authorization; + const match = /^Bearer\s+([^\s]+)$/i.exec(authorization || ''); + return match ? match[1] : null; +} + +function createPassengerTelemetryAuthenticator({ + getFirebaseAuth = createFirebaseAuth, + authLogger = logger +} = {}) { + return async (req, res, next) => { + const token = getBearerToken(req); + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + let firebaseAuth; + try { + firebaseAuth = getFirebaseAuth(); + } catch (error) { + authLogger.error('Passenger telemetry authentication is unavailable', { + firebaseCode: error.code || 'unknown' + }); + return res.status(503).json({ error: 'Passenger telemetry authentication is unavailable' }); + } + + try { + // Verify the Firebase ID token, including revocation. The decoded token and UID are + // deliberately discarded: telemetry identity is the installation-scoped sessionId only. + await firebaseAuth.verifyIdToken(token, true); + req.passengerTelemetryAuthenticated = true; + return next(); + } catch (error) { + authLogger.warn('Passenger telemetry token was rejected', { + firebaseCode: error.code || 'unknown' + }); + return res.status(403).json({ error: 'Invalid or expired token' }); + } + }; +} + +const authenticatePassengerTelemetry = createPassengerTelemetryAuthenticator(); + +module.exports = { + createPassengerTelemetryAuthenticator, + authenticatePassengerTelemetry, + getBearerToken +}; diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js index 0bfadb9..b464435 100644 --- a/backend/src/routes/api.js +++ b/backend/src/routes/api.js @@ -12,6 +12,7 @@ const syncController = require('../controllers/syncController'); const telemetryController = require('../controllers/telemetryController'); const passengerTelemetryController = require('../controllers/passengerTelemetryController'); const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter'); +const { authenticatePassengerTelemetry } = require('../middleware/passengerTelemetryAuth'); const { authenticateToken, requireAdmin } = require('../middleware/auth'); const { resolveCity } = require('../middleware/city'); const qualityController = require('../controllers/qualityController'); @@ -78,6 +79,7 @@ router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryContro router.post( '/passenger-telemetry/ingest', createPassengerTelemetryRateLimiter(), + authenticatePassengerTelemetry, passengerTelemetryController.ingest.bind(passengerTelemetryController) ); router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController)); diff --git a/backend/test/passengerTelemetryAuth.test.js b/backend/test/passengerTelemetryAuth.test.js new file mode 100644 index 0000000..9f350c3 --- /dev/null +++ b/backend/test/passengerTelemetryAuth.test.js @@ -0,0 +1,109 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + createPassengerTelemetryAuthenticator, + getBearerToken +} = require('../src/middleware/passengerTelemetryAuth'); + +function createResponse() { + return { + statusCode: null, + body: null, + status(statusCode) { + this.statusCode = statusCode; + return this; + }, + json(body) { + this.body = body; + return this; + } + }; +} + +function createRequest(authorization) { + return { + headers: { authorization }, + get(name) { + return name.toLowerCase() === 'authorization' ? authorization : undefined; + } + }; +} + +test('requires a Bearer token for passenger telemetry', async () => { + const middleware = createPassengerTelemetryAuthenticator({ + getFirebaseAuth() { + throw new Error('should not initialize Firebase without a token'); + } + }); + const res = createResponse(); + + await middleware(createRequest(), res, () => assert.fail('next should not run')); + + assert.equal(res.statusCode, 401); + assert.deepEqual(res.body, { error: 'Access token required' }); +}); + +test('accepts a verified Firebase ID token without retaining its UID', async () => { + let verifiedToken; + const middleware = createPassengerTelemetryAuthenticator({ + getFirebaseAuth() { + return { + async verifyIdToken(token, checkRevoked) { + verifiedToken = token; + assert.equal(checkRevoked, true); + return { uid: 'must-not-be-copied-to-request' }; + } + }; + } + }); + const req = createRequest('Bearer firebase-id-token'); + const res = createResponse(); + let advanced = false; + + await middleware(req, res, () => { + advanced = true; + }); + + assert.equal(verifiedToken, 'firebase-id-token'); + assert.equal(advanced, true); + assert.equal(req.passengerTelemetryAuthenticated, true); + assert.equal('user' in req, false); + assert.equal('firebaseUid' in req, false); +}); + +test('rejects invalid Firebase ID tokens without logging their value', async () => { + const warnings = []; + const middleware = createPassengerTelemetryAuthenticator({ + getFirebaseAuth() { + return { + async verifyIdToken() { + const error = new Error('invalid token should not be logged'); + error.code = 'auth/argument-error'; + throw error; + } + }; + }, + authLogger: { + warn(message, metadata) { + warnings.push({ message, metadata }); + }, + error() {} + } + }); + const res = createResponse(); + + await middleware(createRequest('Bearer private-token-value'), res, () => assert.fail('next should not run')); + + assert.equal(res.statusCode, 403); + assert.deepEqual(warnings, [{ + message: 'Passenger telemetry token was rejected', + metadata: { firebaseCode: 'auth/argument-error' } + }]); +}); + +test('parses only a complete Bearer authorization value', () => { + assert.equal(getBearerToken(createRequest('Bearer token')), 'token'); + assert.equal(getBearerToken(createRequest('Basic token')), null); + assert.equal(getBearerToken(createRequest('Bearer token extra')), null); +}); diff --git a/docs/fcm-alert-notifications.md b/docs/fcm-alert-notifications.md index 671b795..45e3b94 100644 --- a/docs/fcm-alert-notifications.md +++ b/docs/fcm-alert-notifications.md @@ -107,8 +107,9 @@ docker compose exec -T postgres \ | `FIREBASE_PROJECT_ID` | пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. | | `GOOGLE_APPLICATION_CREDENTIALS` | пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. | -`FCM_ENABLED=false` — безопасный режим по умолчанию. В нём приложение не -пытается инициализировать Firebase и полностью работает без credentials. +`FCM_ENABLED=false` — безопасный режим по умолчанию для push-уведомлений. Firebase +Admin всё равно потребуется backend, если включён пассажирский режим: endpoint +`/api/v1/passenger-telemetry/ingest` проверяет Firebase ID token независимо от FCM. Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend также должен использовать Node.js 22.