From 5e782195ce1886470b54ab5f99c3449faa2d371f Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sun, 13 Aug 2023 10:33:24 +0200 Subject: [PATCH 1/4] feat(backend): Track lastSeen and calls in middleware --- sites/backend/prisma/schema.prisma | 3 +- sites/backend/src/middleware.mjs | 24 +++++++++++++++- sites/backend/src/models/user.mjs | 46 ++++++++++++++++++++++++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 4d5b012dea4..72c90ab7da2 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -42,6 +42,7 @@ model User { id Int @id @default(autoincrement()) apikeys Apikey[] bio String @default("") + calls Int @default(0) compare Boolean @default(true) confirmations Confirmation[] consent Int @default(0) @@ -55,7 +56,7 @@ model User { initial String imperial Boolean @default(false) language String @default("en") - lastSignIn DateTime? + lastSeen DateTime? lusername String @unique mfaSecret String @default("") mfaEnabled Boolean @default(false) diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index f25589fedf4..18e5cb2f136 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -2,6 +2,18 @@ import cors from 'cors' import http from 'passport-http' import jwt from 'passport-jwt' import { ApikeyModel } from './models/apikey.mjs' +import { UserModel } from './models/user.mjs' + +/* + * In v2 we ended up with a bug where we did not properly track the last login + * So in v3 we switch to `lastSeen` and every authenticated API call we update + * this field. It's a bit of a perf hit to write to the database on ever API call + * but it's worth it to actually know which accounts are used and which are not. + */ +async function updateLastSeen(uid, tools) { + const User = new UserModel(tools) + await User.seen(uid) +} function loadExpressMiddleware(app) { app.use(cors()) @@ -12,6 +24,11 @@ function loadPassportMiddleware(passport, tools) { new http.BasicStrategy(async (key, secret, done) => { const Apikey = new ApikeyModel(tools) await Apikey.verify(key, secret) + /* + * Update lastSeen field + */ + if (Apikey.verified) await updateLastSeen(Apikey.record.userId, tools) + return Apikey.verified ? done(null, { ...Apikey.record, apikey: true, uid: Apikey.record.userId }) : done(false) @@ -23,7 +40,12 @@ function loadPassportMiddleware(passport, tools) { jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), ...tools.config.jwt, }, - (jwt_payload, done) => { + async (jwt_payload, done) => { + /* + * Update lastSeen field + */ + await updateLastSeen(jwt_payload._id, tools) + return done(null, { ...jwt_payload, uid: jwt_payload._id, diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index f23ebee3830..7c02e0cbb72 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -461,9 +461,10 @@ UserModel.prototype.passwordSignIn = async function (req) { * have their password and we know it's good, let's rehash it the v3 way * if this happens to be a v2 user. */ - const update = { lastSignIn: new Date() } - if (updatedPasswordField) update.password = updatedPasswordField - await this.update(update) + if (updatedPasswordField) { + update.password = updatedPasswordField + await this.update(update) + } /* * Final check for account status and other things before returning @@ -703,7 +704,6 @@ UserModel.prototype.confirm = async function ({ body, params }) { await this.update({ status: 1, consent: body.consent, - lastSignIn: new Date(), }) /* @@ -1100,7 +1100,7 @@ UserModel.prototype.asAccount = function () { imperial: this.record.imperial, initial: this.clear.initial, language: this.record.language, - lastSignIn: this.record.lastSignIn, + lastSeen: this.record.lastSeen, mfaEnabled: this.record.mfaEnabled, newsletter: this.record.newsletter, patron: this.record.patron, @@ -1109,6 +1109,10 @@ UserModel.prototype.asAccount = function () { updatedAt: this.record.updatedAt, username: this.record.username, lusername: this.record.lusername, + /* + * Add this so we can give a note to users about migrating their password + */ + passwordType: JSON.parse(this.record.password).type, } } @@ -1206,6 +1210,36 @@ UserModel.prototype.isLusernameAvailable = async function (lusername) { return true } +/* + * Helper method to update the `lastSeen` field of the user + * This is called from middleware with the user ID passed in. + * + * @param {id} string - The user ID + * @returns {isTest} boolean - True if it's a test. False if not. + */ +UserModel.prototype.seen = async function (id) { + try { + await this.prisma.user.update({ + where: { id }, + data: { + lastSeen: new Date(), + calls: { increment: 1 }, + }, + }) + } catch (err) { + /* + * An error means it's not good. Return false + */ + log.warn({ id, err }, 'Could not update lastSeen field from middleware') + return false + } + + /* + * If we get here, the lastSeen field was updated and user exists, so return true + */ + return true +} + /* * Everything below this comment is migration code. * This can all be removed after v3 is in production and all users have been migrated. @@ -1228,7 +1262,7 @@ const migrateUser = (v2) => { initial, imperial: v2.units === 'imperial', language: v2.settings.language, - lastSignIn: v2.time?.login ? v2.time.login : null, + lastSeen: Date.now(), lusername: v2.username.toLowerCase(), mfaEnabled: false, newsletter: false, From 571ccf14133071cc41e314c06950316da9791dce Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sun, 13 Aug 2023 10:41:11 +0200 Subject: [PATCH 2/4] feat(backend): Track jwt/key calls seperately --- sites/backend/prisma/schema.prisma | 3 ++- sites/backend/src/middleware.mjs | 8 ++++---- sites/backend/src/models/user.mjs | 23 ++++++++++++++--------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 72c90ab7da2..d7ef9fc6ce0 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -42,7 +42,6 @@ model User { id Int @id @default(autoincrement()) apikeys Apikey[] bio String @default("") - calls Int @default(0) compare Boolean @default(true) confirmations Confirmation[] consent Int @default(0) @@ -55,6 +54,8 @@ model User { img String? initial String imperial Boolean @default(false) + jwtCalls Int @default(0) + keyCalls Int @default(0) language String @default("en") lastSeen DateTime? lusername String @unique diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 18e5cb2f136..dd4684b2222 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -10,9 +10,9 @@ import { UserModel } from './models/user.mjs' * this field. It's a bit of a perf hit to write to the database on ever API call * but it's worth it to actually know which accounts are used and which are not. */ -async function updateLastSeen(uid, tools) { +async function updateLastSeen(uid, tools, type) { const User = new UserModel(tools) - await User.seen(uid) + await User.seen(uid, type) } function loadExpressMiddleware(app) { @@ -27,7 +27,7 @@ function loadPassportMiddleware(passport, tools) { /* * Update lastSeen field */ - if (Apikey.verified) await updateLastSeen(Apikey.record.userId, tools) + if (Apikey.verified) await updateLastSeen(Apikey.record.userId, tools, 'key') return Apikey.verified ? done(null, { ...Apikey.record, apikey: true, uid: Apikey.record.userId }) @@ -44,7 +44,7 @@ function loadPassportMiddleware(passport, tools) { /* * Update lastSeen field */ - await updateLastSeen(jwt_payload._id, tools) + await updateLastSeen(jwt_payload._id, tools, 'jwt') return done(null, { ...jwt_payload, diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 7c02e0cbb72..7394d329a50 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1089,6 +1089,7 @@ UserModel.prototype.asAccount = function () { return { id: this.record.id, bio: this.clear.bio, + calls: this.record.calls, compare: this.record.compare, consent: this.record.consent, control: this.record.control, @@ -1215,17 +1216,21 @@ UserModel.prototype.isLusernameAvailable = async function (lusername) { * This is called from middleware with the user ID passed in. * * @param {id} string - The user ID - * @returns {isTest} boolean - True if it's a test. False if not. + * @param {type} string - The authentication type (one of 'jwt' or 'key') + * @returns {success} boolean - True if it worked, false if not */ -UserModel.prototype.seen = async function (id) { +UserModel.prototype.seen = async function (id, type) { + /* + * Construct data object for update operation + */ + const data = { lastSeen: new Date() } + data[`${type}Calls`] = { increment: 1 } + + /* + * Now update the dabatase record + */ try { - await this.prisma.user.update({ - where: { id }, - data: { - lastSeen: new Date(), - calls: { increment: 1 }, - }, - }) + await this.prisma.user.update({ where: { id }, data }) } catch (err) { /* * An error means it's not good. Return false From 5a44e2acce2454b00d6625cae37201836229064c Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sun, 13 Aug 2023 10:42:46 +0200 Subject: [PATCH 3/4] fix(backend): Also split calls in output --- sites/backend/src/models/user.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 7394d329a50..fafb00d0ce3 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1089,7 +1089,6 @@ UserModel.prototype.asAccount = function () { return { id: this.record.id, bio: this.clear.bio, - calls: this.record.calls, compare: this.record.compare, consent: this.record.consent, control: this.record.control, @@ -1100,6 +1099,8 @@ UserModel.prototype.asAccount = function () { img: this.clear.img, imperial: this.record.imperial, initial: this.clear.initial, + jwtCalls: this.record.jwtCalls, + keyCalls: this.record.keyCalls, language: this.record.language, lastSeen: this.record.lastSeen, mfaEnabled: this.record.mfaEnabled, From 59242f16cd4ced789b576ec41cfe586c79895055 Mon Sep 17 00:00:00 2001 From: joostdecock Date: Sun, 13 Aug 2023 10:51:44 +0200 Subject: [PATCH 4/4] fix(backen): Incorrect refactor --- sites/backend/src/models/user.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index fafb00d0ce3..b3e56941d0b 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -461,10 +461,7 @@ UserModel.prototype.passwordSignIn = async function (req) { * have their password and we know it's good, let's rehash it the v3 way * if this happens to be a v2 user. */ - if (updatedPasswordField) { - update.password = updatedPasswordField - await this.update(update) - } + if (updatedPasswordField) await this.update({ password: updatedPasswordField }) /* * Final check for account status and other things before returning