Files
overleaf-cep/services/web/app/src/Features/Captcha/DeviceHistory.js
Jakob Ackermann 8e77ada424 Merge pull request #6417 from overleaf/jpa-device-history
[web] add cookie/JWE based device history for skipping captcha challenge

GitOrigin-RevId: b091564bfd93f7e587d396c860fd864f220f4b63
2022-01-27 09:03:34 +00:00

104 lines
2.9 KiB
JavaScript

const crypto = require('crypto')
const jose = require('jose')
const Metrics = require('@overleaf/metrics')
const Settings = require('@overleaf/settings')
const COOKIE_NAME = Settings.deviceHistory.cookieName
const ENTRY_EXPIRY = Settings.deviceHistory.entryExpiry
const MAX_ENTRIES = Settings.deviceHistory.maxEntries
let SECRET
if (Settings.deviceHistory.secret) {
SECRET = crypto.createSecretKey(
Buffer.from(Settings.deviceHistory.secret, 'hex')
)
}
const CONTENT_ENCRYPTION_ALGORITHM = 'A256GCM'
const KEY_MANAGEMENT_ALGORITHM = 'A256GCMKW'
const ENCRYPTION_HEADER = {
alg: KEY_MANAGEMENT_ALGORITHM,
enc: CONTENT_ENCRYPTION_ALGORITHM,
}
const DECRYPTION_OPTIONS = {
contentEncryptionAlgorithms: [CONTENT_ENCRYPTION_ALGORITHM],
keyManagementAlgorithms: [KEY_MANAGEMENT_ALGORITHM],
}
const ENCODER = new TextEncoder()
const DECODER = new TextDecoder()
class DeviceHistory {
constructor() {
this.entries = []
}
has(email) {
return this.entries.some(entry => entry.e === email)
}
add(email) {
// Entries are sorted by age, starting from oldest (idx 0) to newest.
// When parsing/serializing we are looking at the last n=MAX_ENTRIES entries
// from the list and discard any other stale entries.
this.entries = this.entries.filter(entry => entry.e !== email)
this.entries.push({ e: email, t: Date.now() })
}
async serialize(res) {
let v = ''
if (this.entries.length > 0 && SECRET) {
v = await new jose.CompactEncrypt(
ENCODER.encode(JSON.stringify(this.entries.slice(-MAX_ENTRIES)))
)
.setProtectedHeader(ENCRYPTION_HEADER)
.encrypt(SECRET)
}
const options = {
domain: Settings.cookieDomain,
maxAge: ENTRY_EXPIRY,
secure: Settings.secureCookie,
sameSite: Settings.sameSiteCookie,
httpOnly: true,
path: '/login',
}
if (v) {
res.cookie(COOKIE_NAME, v, options)
} else {
options.maxAge = -1
res.clearCookie(COOKIE_NAME, options)
}
}
async parse(req) {
const blob = req.cookies[COOKIE_NAME]
if (!blob || !SECRET) {
Metrics.inc('device_history', 1, { status: 'missing' })
return
}
try {
const { plaintext } = await jose.compactDecrypt(
blob,
SECRET,
DECRYPTION_OPTIONS
)
const minTimestamp = Date.now() - ENTRY_EXPIRY
this.entries = JSON.parse(DECODER.decode(plaintext))
.slice(-MAX_ENTRIES)
.filter(entry => entry.t > minTimestamp)
} catch (err) {
Metrics.inc('device_history', 1, { status: 'failure' })
throw err
}
if (this.entries.length === MAX_ENTRIES) {
// Track hitting the limit, we might need to increase the limit.
Metrics.inc('device_history_at_limit')
}
// Collect quantiles of the size
Metrics.summary('device_history_size', this.entries.length)
Metrics.inc('device_history', 1, { status: 'success' })
}
}
module.exports = DeviceHistory