From 9c92542653a0e040e1a771e20e777ff7f8ed9732 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 16 Jan 2026 22:03:35 +0000 Subject: [PATCH] recovery --- server/src/config/services.ts | 6 ++ server/src/db/models/RecoveryToken.js | 25 -------- server/src/db/models/RecoveryToken.ts | 27 ++++++++ server/src/db/models/index.ts | 3 + server/src/repositories/index.ts | 3 + .../IRecoveryTokensRepository.ts | 12 ++++ .../MongoRecoveryTokensRepository.ts | 61 +++++++++++++++++++ server/src/service/business/userService.ts | 40 +++++++++--- .../service/infrastructure/emailService.js | 2 +- server/src/types/index.ts | 1 + server/src/types/invite.ts | 2 +- server/src/types/recoveryToken.ts | 8 +++ 12 files changed, 154 insertions(+), 36 deletions(-) delete mode 100755 server/src/db/models/RecoveryToken.js create mode 100644 server/src/db/models/RecoveryToken.ts create mode 100644 server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts create mode 100644 server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts create mode 100644 server/src/types/recoveryToken.ts diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 66fb18f47..aef32440f 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -73,12 +73,14 @@ import { MongoStatusPagesRepository, MongoUsersRepository, MongoInvitesRepository, + MongoRecoveryTokensRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, IStatusPagesRepository, IUsersRepository, IInvitesRepository, + IRecoveryTokensRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -110,6 +112,7 @@ export type InitializedSerivces = { statusPagesRepository: IStatusPagesRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; + recoveryTokensRepository: IRecoveryTokensRepository; }; export const initializeServices = async ({ @@ -163,6 +166,7 @@ export const initializeServices = async ({ const statusPagesRepository = new MongoStatusPagesRepository(); const usersRepository = new MongoUsersRepository(); const invitesRepository = new MongoInvitesRepository(); + const recoveryTokensRepository = new MongoRecoveryTokensRepository(); const networkService = new NetworkService({ axios, @@ -247,6 +251,7 @@ export const initializeServices = async ({ monitorsRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }); const diagnosticService = new DiagnosticService(); @@ -307,6 +312,7 @@ export const initializeServices = async ({ statusPagesRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/db/models/RecoveryToken.js b/server/src/db/models/RecoveryToken.js deleted file mode 100755 index 2219a4bca..000000000 --- a/server/src/db/models/RecoveryToken.js +++ /dev/null @@ -1,25 +0,0 @@ -import mongoose from "mongoose"; - -const RecoveryTokenSchema = mongoose.Schema( - { - email: { - type: String, - required: true, - unique: true, - }, - token: { - type: String, - required: true, - }, - expiry: { - type: Date, - default: Date.now, - expires: 600, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("RecoveryToken", RecoveryTokenSchema); diff --git a/server/src/db/models/RecoveryToken.ts b/server/src/db/models/RecoveryToken.ts new file mode 100644 index 000000000..07b697d9e --- /dev/null +++ b/server/src/db/models/RecoveryToken.ts @@ -0,0 +1,27 @@ +import { Schema, model, type Types } from "mongoose"; +import type { RecoveryToken as RecoveryTokenEntity } from "@/types/recoveryToken.js"; + +type RecoveryTokenDocumentBase = Omit & { + expiry: Date; +}; + +interface RecoveryTokenDocument extends RecoveryTokenDocumentBase { + _id: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const RecoveryTokenSchema = new Schema( + { + email: { type: String, required: true, unique: true }, + token: { type: String, required: true }, + expiry: { type: Date, default: Date.now, expires: 600 }, + }, + { timestamps: true } +); + +const RecoveryTokenModel = model("RecoveryToken", RecoveryTokenSchema); + +export type { RecoveryTokenDocument }; +export { RecoveryTokenModel }; +export default RecoveryTokenModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d378c6750..1e5024b1c 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -15,3 +15,6 @@ export { default as UserModel } from "@/db/models/User.js"; export * from "@/db/models/Invite.js"; export { default as InviteModel } from "@/db/models/Invite.js"; + +export * from "@/db/models/RecoveryToken.js"; +export { default as RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 7afe3a9f4..7a3c08c81 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -15,3 +15,6 @@ export { default as MongoUsersRepository } from "@/repositories/users/MongoUsers export * from "@/repositories/invites/IInvitesRepository.js"; export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; + +export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; +export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; diff --git a/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts new file mode 100644 index 000000000..9df1fa793 --- /dev/null +++ b/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts @@ -0,0 +1,12 @@ +import type { RecoveryToken } from "@/types/recoveryToken.js"; + +export interface IRecoveryTokensRepository { + // create + create(email: string): Promise; + // fetch + findByToken(token: string): Promise; + // update + // delete + deleteManyByEmail(email: string): Promise; + // other +} diff --git a/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts new file mode 100644 index 000000000..4c9f9ebb6 --- /dev/null +++ b/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts @@ -0,0 +1,61 @@ +import type { RecoveryToken } from "@/types/index.js"; +import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; +import type { RecoveryTokenDocument } from "@/db/models/RecoveryToken.js"; +import { RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; +import mongoose from "mongoose"; +import crypto from "crypto"; +import { AppError } from "@/utils/AppError.js"; +const SERVICE_NAME = "MongoRecoveryTokensRepository"; + +class MongoRecoveryTokensRepository implements IRecoveryTokensRepository { + static SERVICE_NAME = SERVICE_NAME; + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: RecoveryTokenDocument): RecoveryToken => { + return { + id: this.toStringId(doc._id), + email: doc.email, + token: doc.token, + expiry: this.toDateString(doc.expiry), + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + create = async (email: string): Promise => { + const token = await RecoveryTokenModel.create({ + email, + token: crypto.randomBytes(32).toString("hex"), + }); + return this.toEntity(token); + }; + + findByToken = async (token: string): Promise => { + const recoveryToken = await RecoveryTokenModel.findOne({ token }); + if (!recoveryToken) { + throw new AppError({ message: "Recovery token not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(recoveryToken); + }; + + deleteManyByEmail = async (email: string) => { + const result = await RecoveryTokenModel.deleteMany({ + email, + }); + return Promise.resolve(result.deletedCount || 0); + }; +} + +export default MongoRecoveryTokensRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index dca362ebe..efd599299 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -1,7 +1,8 @@ -import { IInvitesRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; +import { IInvitesRepository, IMonitorsRepository, IRecoveryTokensRepository, IUsersRepository } from "@/repositories/index.js"; import Team from "@/db/models/Team.js"; import type { User } from "@/types/index.js"; import bcrypt from "bcryptjs"; +import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "userService"; @@ -20,6 +21,7 @@ class UserService { private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; private invitesRepository: IInvitesRepository; + private recoveryTokensRepository: IRecoveryTokensRepository; constructor({ crypto, @@ -34,6 +36,7 @@ class UserService { monitorsRepository, usersRepository, invitesRepository, + recoveryTokensRepository, }: { crypto: any; db: any; @@ -47,6 +50,7 @@ class UserService { monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; + recoveryTokensRepository: IRecoveryTokensRepository; }) { this.db = db; this.emailService = emailService; @@ -60,6 +64,7 @@ class UserService { this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; this.invitesRepository = invitesRepository; + this.recoveryTokensRepository = recoveryTokensRepository; } get serviceName() { @@ -178,13 +183,15 @@ class UserService { }; checkSuperadminExists = async () => { - const superAdminExists = await this.db.userModule.checkSuperadmin(); - return superAdminExists; + return await this.usersRepository.findSuperAdmin(); }; requestRecovery = async (email: string) => { const user = await this.db.userModule.getUserByEmail(email); - const recoveryToken = await this.db.recoveryModule.requestRecoveryToken(email); + + // Delete existing tokens + await this.recoveryTokensRepository.deleteManyByEmail(email); + const recoveryToken = await this.recoveryTokensRepository.create(email); const name = user.firstName; const { clientHost } = this.settingsService.getSettings(); const url = `${clientHost}/set-new-password/${recoveryToken.token}`; @@ -199,14 +206,29 @@ class UserService { }; validateRecovery = async (recoveryToken: string) => { - await this.db.recoveryModule.validateRecoveryToken(recoveryToken); + // Throws if token not found, validating + await this.recoveryTokensRepository.findByToken(recoveryToken); }; resetPassword = async (password: string, recoveryToken: string) => { - const user = await this.db.recoveryModule.resetPassword(password, recoveryToken); - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(user._doc, appSettings); - return { user, token }; + const existingToken = await this.recoveryTokensRepository.findByToken(recoveryToken); + const existingUser = await this.usersRepository.findByEmail(existingToken.email); + + const match = await bcrypt.compare(password, existingUser.password); + if (match === true) { + throw new AppError({ message: "New password cannot be same as old password", service: SERVICE_NAME, status: 400 }); + } + + existingUser.password = password; + await this.usersRepository.updateById(existingUser.id, existingUser, null); + await this.recoveryTokensRepository.deleteManyByEmail(existingUser.email); + + existingUser.password = ""; + existingUser.profileImage = undefined; + + const token = this.issueToken(existingUser, await this.settingsService.getSettings()); + + return { user: existingUser, token }; }; deleteUser = async (user: any) => { diff --git a/server/src/service/infrastructure/emailService.js b/server/src/service/infrastructure/emailService.js index ed661ff16..bc040d51a 100755 --- a/server/src/service/infrastructure/emailService.js +++ b/server/src/service/infrastructure/emailService.js @@ -46,7 +46,7 @@ class EmailService { */ this.loadTemplate = (templateName) => { try { - const templatePath = this.path.join(__dirname, `../../../templates/${templateName}.mjml`); + const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error) { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 9416de9fa..1a43d4849 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -5,3 +5,4 @@ export * from "@/types/statusPage.js"; export * from "@/types/network.js"; export * from "@/types/user.js"; export * from "@/types/invite.js"; +export * from "@/types/recoveryToken.js"; diff --git a/server/src/types/invite.ts b/server/src/types/invite.ts index 8e65d54e9..cdbdb37e5 100644 --- a/server/src/types/invite.ts +++ b/server/src/types/invite.ts @@ -1,4 +1,4 @@ -import { UserRole } from "./user.js"; +import type { UserRole } from "@/types/user.js"; export interface Invite { id: string; diff --git a/server/src/types/recoveryToken.ts b/server/src/types/recoveryToken.ts new file mode 100644 index 000000000..488e1f4c1 --- /dev/null +++ b/server/src/types/recoveryToken.ts @@ -0,0 +1,8 @@ +export interface RecoveryToken { + id: string; + email: string; + token: string; + expiry: string; + createdAt: string; + updatedAt: string; +}