Files
Lauri Ojansivu c940b3e301 Sign in with SQRL, from a phone or a computer.
SQRL (https://www.grc.com/sqrl/sqrl.htm) signs in with a SQRL app or client
instead of a password. A site admin turns it on in Admin Panel -> Settings,
off by default; WeKan must be at an https:// ROOT_URL. A member adds their
identity once in Member Settings -> SQRL, by scanning the QR code or
opening their SQRL client; the sign-in page then offers Sign in with SQRL,
shows the sqrl:// link and its QR code, and signs in by itself once the
client has answered. With the client on the same computer (cps), the
client opens WeKan with a one-time token instead.

The protocol is GRC's SQRL Semantics: the client posts signed requests to
/sqrl, each verified with Ed25519 by Node.js's own crypto, no new
dependency for it. Every nut is random and used once, taken atomically;
each request must carry the server's previous reply unchanged; a request
from another address than the sign-in page's is refused unless the client
says noiptest; disable needs the identity's signature, and enable, remove
and rekeying need the rescue code's unlock key. An identity WeKan does not
know can neither make an account nor sign in. The page learns of success
by polling with a secret only it holds, and signing in uses that once.
The identity's public keys are a user field never published and not
writable from the client. Not offered on Sandstorm. sqrlonly and hardlock
are not supported. The QR code is qrcode-generator 1.4.4 (MIT, pinned).

models/lib/sqrl.js is the protocol with storage and signature check passed
in; tests/sqrl.test.cjs drives it with a software SQRL client using real
Ed25519 keys - adding an identity, the waiting page, cps, another network,
forged signatures, replayed nuts, altered replies, unknown identities,
another member's identity, disable/enable, rekey and remove - and pins the
server's single-use and visibility rules and the UI.
server/lib/tests/sqrl.tests.js runs in Meteor (2 passing): off by
default, then an identity added and signed in with once, through the real
/sqrl route and MongoDB. Documented in docs/Features/Login/SQRL.md. The
new strings are English placeholders until the translations that follow.

Thanks to xet7 !

Fixes #2460.
2026-10-14 17:02:20 +03:00

146 lines
5.9 KiB
JavaScript

// Counting REST API calls into the `api` stream of Admin Panel → Problems.
//
// The decisions are in models/lib/apiUsage.js - what an endpoint is called, and
// why calls are accumulated rather than written one by one. This is the part
// that touches Express and Mongo.
//
// WHERE IT SITS. One middleware, in front of every /api route, that counts on
// the response's `finish` event rather than on the way in. Two reasons, and both
// matter: by then Express has filled `req.route.path`, so the name is the route
// PATTERN (`/api/boards/:boardId`) rather than the concrete path - which is what
// keeps one row per endpoint instead of one per board - and by then
// authentication has run, so the call has a username. A route cannot be added
// without being counted, because nothing is registered per route.
//
// It must never affect the request. Everything here is wrapped, the counting is
// in memory, and the write happens on a timer in another tick.
import { Meteor } from 'meteor/meteor';
import { foldEvent } from '/server/lib/eventLogFold';
const { apiName, isApiRequest, UsageAccumulator } = require('/models/lib/apiUsage');
const { locationFromHeaders } = require('/models/lib/geoHeaders');
// How often counts are written. Long enough that a burst is one write, short
// enough that the report is not visibly stale while somebody is watching it.
const FLUSH_MS = Number(process.env.WEKAN_API_USAGE_FLUSH_MS) || 10000;
// A flush is also forced at this many distinct rows, so a sudden spread does not
// wait out the timer holding everything in memory.
const FLUSH_AT_ROWS = 200;
const accumulator = new UsageAccumulator();
let timer = null;
// The address the call came from, resolved the same spoofing-safe way as the
// login throttle and the lockout: X-Forwarded-For only as far as
// HTTP_FORWARDED_COUNT says to trust it, and the socket address otherwise. A
// column an attacker can write by sending a header is worse than no column.
export function clientAddress(req) {
const hops = parseInt(process.env.HTTP_FORWARDED_COUNT, 10);
const forwarded = req.headers && req.headers['x-forwarded-for'];
if (hops > 0 && forwarded) {
const parts = String(forwarded).split(',').map(s => s.trim()).filter(Boolean);
const idx = parts.length - hops;
if (idx >= 0 && parts[idx]) return parts[idx];
}
return (req.connection && req.connection.remoteAddress)
|| (req.socket && req.socket.remoteAddress) || '';
}
// userId -> username, for one flush. The account is what a row is FOR, so its
// name has to be on the row; looking it up per flush costs one query per
// distinct caller in that batch, which is a handful.
async function usernamesFor(userIds) {
const names = new Map();
const ids = [...new Set(userIds.filter(Boolean))];
if (!ids.length) return names;
const users = await Meteor.users.find(
{ _id: { $in: ids } }, { fields: { username: 1 } },
).fetchAsync();
for (const user of users) names.set(user._id, user.username || '');
return names;
}
async function flush() {
const rows = accumulator.drain();
if (!rows.length) return;
const names = await usernamesFor(rows.map(r => r.userId));
for (const row of rows) {
// Through the SAME fold as every other stream, so an API row is a summary
// with a count, a window and a per-actor tally like all the rest - and a
// change to how summaries work reaches this one too.
//
// `count` is what the fold multiplies by: the accumulator has already
// counted this endpoint's calls, so this is one write for all of them.
await foldEvent({
stream: 'api',
api: row.name,
// The account is part of the row's IDENTITY here, which is the deliberate
// exception (models/lib/apiUsage.js). It is the ACCOUNT ID, not the name:
// a rename would otherwise split one account's history into two rows, and
// an unauthenticated call has no name at all. The name is carried beside
// it as a display field, the way every other stream carries one.
apiUserId: row.userId || '',
username: names.get(row.userId) || '',
userId: row.userId || '',
// ip only: the fold splits it into ipv4/ipv6 for every stream.
ip: row.ip || '',
location: row.location || undefined,
count: row.count,
at: row.at,
});
}
}
function scheduleFlush() {
if (timer) return;
timer = Meteor.setTimeout(() => {
timer = null;
// Fire and forget: a usage count must never be able to break a request or
// stop the server, and there is nothing to do about a failed write except
// count the next one.
Promise.resolve()
.then(flush)
.catch(e => {
if (process.env.DEBUG === 'true') console.warn('api usage flush failed:', e && e.message);
});
}, FLUSH_MS);
}
// The middleware. Installed by server/apiMiddleware.js, in front of the routes.
export function apiUsageMiddleware(req, res, next) {
try {
if (isApiRequest(req.url)) {
res.on('finish', () => {
try {
accumulator.add({
// req.route is set by Express once a handler has matched, which by
// `finish` it has - or has not, and then the call is counted under
// the one "(no route)" name rather than under a path an attacker
// chose.
name: apiName(req.method, req.route && req.route.path),
userId: req.userId || '',
ip: clientAddress(req),
location: locationFromHeaders(req.headers),
at: new Date(),
});
if (accumulator.size >= FLUSH_AT_ROWS) {
Promise.resolve().then(flush).catch(() => {});
} else {
scheduleFlush();
}
} catch (e) {
// Counting a call must never break the call it counted.
}
});
}
} catch (e) {
// ditto
}
next();
}
// Exported for the tests and for a clean shutdown; nothing else calls it.
export { flush as flushApiUsage, accumulator as apiUsageAccumulator };