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
|
# 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
|
||||||
|
|||||||
@@ -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` точный домен панели
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
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 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));
|
||||||
|
|||||||
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 не определяет проект автоматически. |
|
| `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.
|
||||||
|
|||||||
Reference in New Issue
Block a user