diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 4d5b012dea4..d7ef9fc6ce0 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -54,8 +54,10 @@ model User { img String? initial String imperial Boolean @default(false) + jwtCalls Int @default(0) + keyCalls Int @default(0) 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..dd4684b2222 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, type) { + const User = new UserModel(tools) + await User.seen(uid, type) +} 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, 'key') + 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, 'jwt') + 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..b3e56941d0b 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -461,9 +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. */ - const update = { lastSignIn: new Date() } - 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 @@ -703,7 +701,6 @@ UserModel.prototype.confirm = async function ({ body, params }) { await this.update({ status: 1, consent: body.consent, - lastSignIn: new Date(), }) /* @@ -1099,8 +1096,10 @@ 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, - lastSignIn: this.record.lastSignIn, + lastSeen: this.record.lastSeen, mfaEnabled: this.record.mfaEnabled, newsletter: this.record.newsletter, patron: this.record.patron, @@ -1109,6 +1108,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 +1209,40 @@ 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 + * @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, 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 }) + } 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 +1265,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,