feat: implement Firebase ID token authentication for passenger telemetry and update related configurations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` точный домен панели
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
53
backend/src/middleware/passengerTelemetryAuth.js
Normal file
53
backend/src/middleware/passengerTelemetryAuth.js
Normal 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
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
109
backend/test/passengerTelemetryAuth.test.js
Normal file
109
backend/test/passengerTelemetryAuth.test.js
Normal 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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user