feat: implement Firebase ID token authentication for passenger telemetry and update related configurations

This commit is contained in:
2026-09-18 04:04:43 +03:00
parent 863ac603bc
commit b5c6b1973e
8 changed files with 206 additions and 14 deletions

View File

@@ -39,6 +39,11 @@ ADMIN_EMAIL=admin@polotsk-transit.local
# Frontend # Frontend
API_URL=/api/v1 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) # Production settings (uncomment for production)
# NODE_ENV=production # NODE_ENV=production
# CORS_ORIGIN=https://admin.yourdomain.com # CORS_ORIGIN=https://admin.yourdomain.com

View File

@@ -59,8 +59,13 @@ ADMIN_PASSWORD=your_secure_admin_password_here
CORS_ORIGIN=http://localhost:3001 CORS_ORIGIN=http://localhost:3001
API_URL=/api/v1 API_URL=/api/v1
# Push отключён по умолчанию и не требует Firebase credentials # Push отключён по умолчанию
FCM_ENABLED=false 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` точный домен панели Для входа по passkey на публичном адресе укажите в `.env` точный домен панели

View File

@@ -24,6 +24,18 @@ function getFirebaseConfig(env = process.env) {
let messagingSingleton; 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()) { function createFirebaseMessaging(config = getFirebaseConfig()) {
if (!config.enabled) { if (!config.enabled) {
return null; return null;
@@ -31,16 +43,8 @@ function createFirebaseMessaging(config = getFirebaseConfig()) {
// Keep Firebase optional for disabled/local environments. These modular // Keep Firebase optional for disabled/local environments. These modular
// imports are evaluated only when an enabled notification is actually sent. // 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 { getMessaging } = require('firebase-admin/messaging');
return getMessaging(getFirebaseApp(config));
const existingApp = getApps()[0];
const app = existingApp || initializeApp({
credential: applicationDefault(),
...(config.projectId ? { projectId: config.projectId } : {})
});
return getMessaging(app);
} }
function getFirebaseMessaging(config = getFirebaseConfig()) { function getFirebaseMessaging(config = getFirebaseConfig()) {
@@ -55,11 +59,18 @@ function getFirebaseMessaging(config = getFirebaseConfig()) {
return messagingSingleton; return messagingSingleton;
} }
function createFirebaseAuth(config = getFirebaseConfig()) {
const { getAuth } = require('firebase-admin/auth');
return getAuth(getFirebaseApp(config));
}
module.exports = { module.exports = {
DEFAULT_FCM_TOPIC, DEFAULT_FCM_TOPIC,
DEFAULT_FCM_ANDROID_CHANNEL_ID, DEFAULT_FCM_ANDROID_CHANNEL_ID,
parseBoolean, parseBoolean,
getFirebaseConfig, getFirebaseConfig,
getFirebaseApp,
createFirebaseAuth,
createFirebaseMessaging, createFirebaseMessaging,
getFirebaseMessaging getFirebaseMessaging
}; };

View File

@@ -77,9 +77,15 @@ try {
logger.warn('Upload routes disabled - multer not installed. Run: npm install multer'); 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', 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 createRateLimiter(1000, 3600000), // 1000 requests per hour
apiRoutes apiRoutes
); );

View File

@@ -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
};

View File

@@ -12,6 +12,7 @@ const syncController = require('../controllers/syncController');
const telemetryController = require('../controllers/telemetryController'); const telemetryController = require('../controllers/telemetryController');
const passengerTelemetryController = require('../controllers/passengerTelemetryController'); const passengerTelemetryController = require('../controllers/passengerTelemetryController');
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter'); const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
const { authenticatePassengerTelemetry } = require('../middleware/passengerTelemetryAuth');
const { authenticateToken, requireAdmin } = require('../middleware/auth'); const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city'); const { resolveCity } = require('../middleware/city');
const qualityController = require('../controllers/qualityController'); const qualityController = require('../controllers/qualityController');
@@ -78,6 +79,7 @@ router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryContro
router.post( router.post(
'/passenger-telemetry/ingest', '/passenger-telemetry/ingest',
createPassengerTelemetryRateLimiter(), createPassengerTelemetryRateLimiter(),
authenticatePassengerTelemetry,
passengerTelemetryController.ingest.bind(passengerTelemetryController) passengerTelemetryController.ingest.bind(passengerTelemetryController)
); );
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController)); router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));

View File

@@ -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);
});

View File

@@ -107,8 +107,9 @@ docker compose exec -T postgres \
| `FIREBASE_PROJECT_ID` | пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. | | `FIREBASE_PROJECT_ID` | пусто | Явный Firebase/Google Cloud project ID; полезен, если ADC не определяет проект автоматически. |
| `GOOGLE_APPLICATION_CREDENTIALS` | пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. | | `GOOGLE_APPLICATION_CREDENTIALS` | пусто | Стандартный путь ADC к service account JSON в окружении запущенного backend-процесса. |
`FCM_ENABLED=false` — безопасный режим по умолчанию. В нём приложение не `FCM_ENABLED=false` — безопасный режим по умолчанию для push-уведомлений. Firebase
пытается инициализировать Firebase и полностью работает без credentials. Admin всё равно потребуется backend, если включён пассажирский режим: endpoint
`/api/v1/passenger-telemetry/ingest` проверяет Firebase ID token независимо от FCM.
Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend Firebase Admin SDK в этом проекте требует Node.js 22. Docker-образ backend
также должен использовать Node.js 22. также должен использовать Node.js 22.