Just an update. Rather than using Random.hexString()
I’ve settled on using a base64 string, as per this page:
const len = 32;
const key = require('crypto').randomBytes(Math.ceil((len * 3) / 4)).toString('base64').slice(0, len),
I enforce auth in the HTTPS header: authorization Basic userId:key
so I can look up the key using userId
which saves me having to put an extra index on the key field itself in mongo.
If anyone's interested, here's my full webapp handler code:
const version = 1;
const codes = {
400: 'Bad request',
401: 'Unauthorized',
404: 'Not found',
405: 'Method not allowed',
429: 'Too many requests',
}
const rateLimits = {
GET: 5000,
POST: 500,
}
WebApp.connectHandlers.use('/api-data/', (req, res, next) => {
var body = "";
const now = new Date; // for logging how long this function takes
req.on('data', Meteor.bindEnvironment(data => body += data));
req.on('end', Meteor.bindEnvironment(() => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
const method = req.method;
// respond to a CORS preflight request: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
if (method=="OPTIONS" && req.headers["access-control-request-method"] && req.headers.origin) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
res.writeHead(200);
res.end();
return;
}
function sendError(code, msg) {
console.warn('api', code, msg || codes[code], method, req.url, req.headers, body);
res.writeHead(code, { 'Content-Type': 'text/plain'});
res.end(msg || codes[code] || '');
}
// check method
if (!rateLimits[method]) return sendError(405);
// check auth
const auth = (req.headers.authorization || '').match(/^basic (\S+):(\S+)$/i);
if (!auth) return sendError(401);
const [, userId, key] = auth;
let user = Meteor.users.findOne({_id: userId, "apiKey.key": key}, {fields: {
profile: 1,
status: 1,
'apiKey.touched': 1,
}});
if (!user) return sendError(401);
// check rate limit
if (now - user.apiKey.touched < rateLimits[method]) return sendError(429);
// get api end point and version
const parts = req.url.substr(1).split('/');
let reqVersion = version; // default to latest version
if (/^v\d+$/.test(parts[0])) {
reqVersion = parts[0].substr(1) * 1;
parts.shift();
}
req.url = parts.join('/');
// this is my only api end point so far...
if (req.url != 'user/status') return sendError(404);
function finish(updated) {
Meteor.users.update(userId, {$set: {"apiKey.touched": now}});
if (updated) {
// the user has been updated, fetch a new copy...
user = Meteor.users.findOne(userId, {fields: {profile: 1, status: 1}});
}
const result = {
status: 'ok',
name: user.profile.name,
out: user.status.out,
location: user.status.status || '',
time: user.status.time || '',
history: user.status.history || [],
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
console.log('api', method, 'v'+reqVersion, req.url, userId, body, (new Date - now)+'ms');
}
if (method=="GET") {
finish();
} else {
let json = '';
try {
json = JSON.parse(body);
} catch(e) {
return sendError(400, 'Could not parse JSON');
}
// process POST request - this is async, hence defining the `finish()` callback function above...
doSomethingAsync(json, () => finish(true /* modified */));
}
}));
});
Edit: added code to respond to CORS preflight request