diff --git a/server/nodemon.json b/server/nodemon.json index 88d6ac686..7371b4904 100755 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,5 +1,5 @@ { "ignore": ["src/locales/*", "*.log", "node_modules/*"], - "watch": ["src/**/*.js", "*.json"], - "ext": "js,json" + "watch": ["src/**/*.js", "src/**/*.ts", "*.json"], + "ext": "ts,js,json" } diff --git a/server/package-lock.json b/server/package-lock.json index 06c1c30b3..e21b28382 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -45,6 +45,8 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/express": "5.0.3", + "@types/jsonwebtoken": "9.0.10", "c8": "10.1.3", "chai": "5.2.0", "eslint": "^9.17.0", @@ -1678,6 +1680,27 @@ "node": ">=10.13.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1685,12 +1708,44 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1705,6 +1760,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", @@ -1714,12 +1794,49 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/relateurl": { "version": "0.2.33", "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.33.tgz", "integrity": "sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw==", "license": "MIT" }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", diff --git a/server/package.json b/server/package.json index 637c785cc..5c655af9d 100755 --- a/server/package.json +++ b/server/package.json @@ -2,7 +2,7 @@ "name": "server", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "dist/index.js", "type": "module", "scripts": { "test": "c8 mocha", @@ -54,6 +54,8 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@types/express": "5.0.3", + "@types/jsonwebtoken": "9.0.10", "c8": "10.1.3", "chai": "5.2.0", "eslint": "^9.17.0", diff --git a/server/src/config/controllers.js b/server/src/config/controllers.js index 5c54579eb..250d04c4b 100644 --- a/server/src/config/controllers.js +++ b/server/src/config/controllers.js @@ -15,6 +15,9 @@ import StatusPageController from "../controllers/v1/statusPageController.js"; import NotificationController from "../controllers/v1/notificationController.js"; import DiagnosticController from "../controllers/v1/diagnosticController.js"; +// V2 +import AuthV2Controller from "../controllers/v2/AuthController.js"; + export const initializeControllers = (services) => { const controllers = {}; const commonDependencies = createCommonDependencies(services.db, services.errorService, services.logger, services.stringService); @@ -62,5 +65,8 @@ export const initializeControllers = (services) => { diagnosticService: services.diagnosticService, }); + // V2 + controllers.authV2Controller = new AuthV2Controller(services.authV2Service, services.inviteV2Service); + return controllers; }; diff --git a/server/src/config/routes.js b/server/src/config/routes.js index 2ec93a2dc..5ef622f37 100644 --- a/server/src/config/routes.js +++ b/server/src/config/routes.js @@ -13,6 +13,8 @@ import LogRoutes from "../routes/v1/logRoutes.js"; import DiagnosticRoutes from "../routes/v1//diagnosticRoute.js"; import NotificationRoutes from "../routes/v1/notificationRoute.js"; +import authV2 from "../routes/v2/auth.js"; + export const setupRoutes = (app, controllers) => { const authRoutes = new AuthRoutes(controllers.authController); const monitorRoutes = new MonitorRoutes(controllers.monitorController); @@ -37,4 +39,12 @@ export const setupRoutes = (app, controllers) => { app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); + + // ******************* + // V2 Routes + // ******************* + + const v2AuthRoutes = new authV2(controllers.authV2Controller); + + app.use("/api/v2/auth", authApiLimiter, v2AuthRoutes.getRouter()); }; diff --git a/server/src/config/services.js b/server/src/config/services.js index 08c2525f3..b2b4ac430 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -48,8 +48,8 @@ import { ParseBoolean } from "../utils/utils.js"; // Models import Check from "../db/models/Check.js"; import Monitor from "../db/models/Monitor.js"; -import User from "../db/models/User.js"; -import InviteToken from "../db/models/InviteToken.js"; +import { User } from "../db/models/auth/User.js"; +import { Invite } from "../db/models/auth/Invite.js"; import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; @@ -68,6 +68,10 @@ import NotificationModule from "../db/mongo/modules/notificationModule.js"; import RecoveryModule from "../db/mongo/modules/recoveryModule.js"; import SettingsModule from "../db/mongo/modules/settingsModule.js"; +// v2 +import AuthV2Service from "../service/v2/business/AuthService.js"; +import InviteV2Service from "../service/v2/business/InviteService.js"; + export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -79,7 +83,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService // Create DB const checkModule = new CheckModule({ logger, Check, Monitor, User }); - const inviteModule = new InviteModule({ InviteToken, crypto, stringService }); + const inviteModule = new InviteModule({ Invite, crypto, stringService }); const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService }); const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); const maintenanceWindowModule = new MaintenanceWindowModule({ MaintenanceWindow }); @@ -208,6 +212,9 @@ export const initializeServices = async ({ logger, envSettings, settingsService games, }); + const authV2Service = new AuthV2Service(); + const inviteV2Service = new InviteV2Service(); + const services = { settingsService, translationService, @@ -227,6 +234,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService monitorService, errorService, logger, + authV2Service, + inviteV2Service, }; Object.values(services).forEach((service) => { diff --git a/server/src/controllers/v2/AuthController.ts b/server/src/controllers/v2/AuthController.ts new file mode 100644 index 000000000..a9d1dd203 --- /dev/null +++ b/server/src/controllers/v2/AuthController.ts @@ -0,0 +1,142 @@ +import { Request, Response, NextFunction } from "express"; +import { encode, decode } from "../../utils/JWTUtils.js"; +import AuthService from "../../service/v2/business/AuthService.js"; +import ApiError from "../../utils/ApiError.js"; +import InviteService from "../../service/v2/business/InviteService.js"; +import { IInvite } from "../../db/models/index.js"; + +export interface IAuthController { + register(req: Request, res: Response, next: NextFunction): Promise; + registerWithInvite(req: Request, res: Response, next: NextFunction): Promise; + login(req: Request, res: Response, next: NextFunction): Promise; + logout(req: Request, res: Response): void; + me(req: Request, res: Response, next: NextFunction): void; +} + +class AuthController implements IAuthController { + private authService: AuthService; + private inviteService: InviteService; + constructor(authService: AuthService, inviteService: InviteService) { + this.authService = authService; + this.inviteService = inviteService; + } + + register = async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, firstName, lastName, password } = req.body; + + if (!email || !firstName || !lastName || !password) { + throw new Error("Email, firstName, lastName, and password are required"); + } + + const result = await this.authService.register({ + email, + firstName, + lastName, + password, + }); + + const token = encode(result); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(201).json({ + message: "User created successfully", + }); + } catch (error) { + next(error); + } + }; + + registerWithInvite = async (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.params.token; + if (!token) { + throw new ApiError("Invite token is required", 400); + } + + const invite: IInvite = await this.inviteService.get(token); + + const { firstName, lastName, password } = req.body; + const email = invite?.email; + const roles = invite?.roles; + + if (!email || !firstName || !lastName || !password || !roles || roles.length === 0) { + throw new Error("Email, firstName, lastName, password, and roles are required"); + } + + const result = await this.authService.registerWithInvite({ + email, + firstName, + lastName, + password, + roles, + }); + + if (!result) { + throw new Error("Registration failed"); + } + + await this.inviteService.delete(invite._id.toString()); + + const jwt = encode(result); + + res.cookie("token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(201).json({ message: "User created successfully" }); + } catch (error) { + next(error); + } + }; + + login = async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, password } = req.body; + // Validation + if (!email || !password) { + throw new ApiError("Email and password are required", 400); + } + const result = await this.authService.login({ email, password }); + + const token = encode(result); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(200).json({ + message: "Login successful", + }); + } catch (error) { + next(error); + } + }; + + logout = (req: Request, res: Response) => { + res.clearCookie("token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + res.status(200).json({ message: "Logout successful" }); + }; + + me = (req: Request, res: Response, next: NextFunction) => { + return res.status(200).json({ message: "OK" }); + }; +} + +export default AuthController; diff --git a/server/src/db/models/InviteToken.js b/server/src/db/models/InviteToken.js deleted file mode 100755 index 079600647..000000000 --- a/server/src/db/models/InviteToken.js +++ /dev/null @@ -1,35 +0,0 @@ -import mongoose from "mongoose"; -const InviteTokenSchema = mongoose.Schema( - { - email: { - type: String, - required: true, - unique: true, - }, - teamId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Team", - immutable: true, - required: true, - }, - role: { - type: Array, - required: true, - default: ["user"], - }, - token: { - type: String, - required: true, - }, - expiry: { - type: Date, - default: Date.now, - expires: 3600, - }, - }, - { - timestamps: true, - } -); - -export default mongoose.model("InviteToken", InviteTokenSchema); diff --git a/server/src/db/models/auth/Invite.ts b/server/src/db/models/auth/Invite.ts new file mode 100755 index 000000000..148ff65cb --- /dev/null +++ b/server/src/db/models/auth/Invite.ts @@ -0,0 +1,68 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IInvite extends Document { + // v1 + _id: Types.ObjectId; + email: string; + teamId: Types.ObjectId; + role: string[]; + token: string; + expiry: Date; + createdAt: Date; + updatedAt: Date; + + // v2 + tokenHash: string; + roles: Types.ObjectId[]; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; +} + +const InviteSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + role: { + type: [String], + required: true, + default: ["user"], + }, + token: { + type: String, + required: true, + }, + expiry: { + type: Date, + default: Date.now, + expires: 3600, + }, + + // v2 + roles: [ + { + type: Schema.Types.ObjectId, + ref: "Role", + required: true, + }, + ], + tokenHash: { type: String, required: true, unique: true }, + createdBy: { type: Schema.Types.ObjectId, ref: "User", required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: "User", required: true }, + }, + { + timestamps: true, + } +); + +export const Invite = mongoose.model("Invite", InviteSchema); diff --git a/server/src/db/models/auth/Role.ts b/server/src/db/models/auth/Role.ts new file mode 100644 index 000000000..e7ffb2f7d --- /dev/null +++ b/server/src/db/models/auth/Role.ts @@ -0,0 +1,45 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IRole extends Document { + _id: Types.ObjectId; + name: string; + description?: string; + permissions: string[]; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +const roleSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + maxlength: 50, + }, + description: { + type: String, + trim: true, + maxlength: 200, + }, + + permissions: [ + { + type: String, + required: true, + }, + ], + isActive: { + type: Boolean, + default: true, + }, + }, + { + timestamps: true, + } +); + +roleSchema.index({ name: 1 }, { unique: true }); + +export const Role = mongoose.model("Role", roleSchema); diff --git a/server/src/db/models/User.js b/server/src/db/models/auth/User.ts similarity index 55% rename from server/src/db/models/User.js rename to server/src/db/models/auth/User.ts index 9368d98b1..82c058ea2 100755 --- a/server/src/db/models/User.js +++ b/server/src/db/models/auth/User.ts @@ -1,11 +1,47 @@ -import mongoose from "mongoose"; -import bcrypt from "bcryptjs"; -import logger from "../../utils/logger.js"; -import Monitor from "./Monitor.js"; -import Team from "./Team.js"; -import Notification from "./Notification.js"; +import mongoose, { Schema, Document, Types } from "mongoose"; -const UserSchema = mongoose.Schema( +import bcrypt from "bcryptjs"; +import logger from "../../../utils/logger.js"; +import Monitor from "../Monitor.js"; +import Team from "../Team.js"; +import Notification from "../Notification.js"; +import { subscribe } from "diagnostics_channel"; + +export const RoleTypes = ["user", "admin", "superadmin", "demo"] as const; +export type RoleType = (typeof RoleTypes)[number]; + +export interface ITokenizedUser { + sub: string; + roles: string[]; +} + +export interface IUser extends Document { + // V1 + _id: Types.ObjectId; + firstName: string; + lastName: string; + email: string; + password: string; + avatarImage: string; + profileImage: { + data: Buffer; + contentType: string; + }; + isActive: boolean; + isVerified: boolean; + role?: RoleType[]; + teamId: Types.ObjectId; + checkTTL: number; + createdAt: Date; + updatedAt: Date; + comparePassword: (submittedPassword: string) => Promise; + // V2 + roles: Types.ObjectId[]; + lastLoginAt?: Date; + version: number; +} + +const UserSchema = new Schema( { firstName: { type: String, @@ -22,7 +58,7 @@ const UserSchema = mongoose.Schema( }, password: { type: String, - required: true, + required: false, }, avatarImage: { type: String, @@ -41,8 +77,8 @@ const UserSchema = mongoose.Schema( }, role: { type: [String], - default: "user", - enum: ["user", "admin", "superadmin", "demo"], + default: ["user"], + enum: RoleTypes, }, teamId: { type: mongoose.Schema.Types.ObjectId, @@ -52,6 +88,22 @@ const UserSchema = mongoose.Schema( checkTTL: { type: Number, }, + + // v2 + roles: [ + { + type: Schema.Types.ObjectId, + ref: "Role", + default: [], + }, + ], + lastLoginAt: { + type: Date, + }, + version: { + type: Number, + default: 1, + }, }, { timestamps: true, @@ -69,7 +121,7 @@ UserSchema.pre("save", function (next) { UserSchema.pre("findOneAndUpdate", function (next) { const update = this.getUpdate(); - if ("password" in update) { + if (update && "password" in update) { const salt = bcrypt.genSaltSync(10); update.password = bcrypt.hashSync(update.password, salt); } @@ -92,15 +144,13 @@ UserSchema.pre("findOneAndDelete", async function (next) { } next(); } catch (error) { - next(error); + next(error as Error); } }); -UserSchema.methods.comparePassword = async function (submittedPassword) { +UserSchema.methods.comparePassword = async function (submittedPassword: string) { const res = await bcrypt.compare(submittedPassword, this.password); return res; }; -const User = mongoose.model("User", UserSchema); - -export default User; +export const User = mongoose.model("User", UserSchema); diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts new file mode 100644 index 000000000..537633bfd --- /dev/null +++ b/server/src/db/models/index.ts @@ -0,0 +1,9 @@ +export { User } from "./auth/User.js"; +export type { IUser } from "./auth/User.js"; +export type { ITokenizedUser } from "./auth/User.js"; +export { Role } from "./auth/Role.js"; +export type { IRole } from "./auth/Role.js"; +export type { IInvite } from "./auth/Invite.js"; +export { Invite } from "./auth/Invite.js"; +export { Migration } from "./migration/Migration.js"; +export type { IMigration } from "./migration/Migration.js"; diff --git a/server/src/db/models/migration/Migration.ts b/server/src/db/models/migration/Migration.ts new file mode 100644 index 000000000..cf76bb48e --- /dev/null +++ b/server/src/db/models/migration/Migration.ts @@ -0,0 +1,34 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IMigration extends Document { + _id: Types.ObjectId; + name: string; + runAt: Date; + success: boolean; + createdAt: Date; + updatedAt: Date; +} + +const migrationSchema = new Schema( + { + name: { + type: String, + required: true, + }, + runAt: { + type: Date, + required: false, + }, + success: { + type: Boolean, + required: false, + }, + }, + { + timestamps: true, + } +); + +migrationSchema.index({ name: 1 }, { unique: true }); + +export const Migration = mongoose.model("Migration", migrationSchema); diff --git a/server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.js b/server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.ts similarity index 90% rename from server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.js rename to server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.ts index 8f6988de4..b44487e87 100644 --- a/server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.js +++ b/server/src/db/mongo/migration/0001_migrateStatusWindowThreshold.ts @@ -1,4 +1,5 @@ import Monitor from "../../models/Monitor.js"; +export const MIGRATION_NAME = "0001_migrateStatusWindowThreshold"; async function migrateStatusWindowThreshold() { try { const monitors = await Monitor.find({ statusWindowThreshold: { $lt: 1 } }); diff --git a/server/src/db/mongo/migration/0002_migrateUsers.ts b/server/src/db/mongo/migration/0002_migrateUsers.ts new file mode 100644 index 000000000..a7cdb76fc --- /dev/null +++ b/server/src/db/mongo/migration/0002_migrateUsers.ts @@ -0,0 +1,56 @@ +import { User, Role } from "../../models/index.js"; +import { Types } from "mongoose"; + +import { DEFAULT_ROLES } from "../../../service/v2/business/AuthService.js"; +export const MIGRATION_NAME = "0002_migrateUsers"; +async function migrateUsers() { + try { + // Create roles if they don't exist + const roleCount = await Role.countDocuments(); + let roles; + if (roleCount === 0) { + const rolePromises = DEFAULT_ROLES.map((roleData) => + new Role({ + ...roleData, + }).save() + ); + roles = await Promise.all(rolePromises); + } else { + roles = await Role.find(); + } + + // Migrate users + const users = await User.find({ + $or: [{ version: { $exists: false } }, { version: { $lt: 2 } }], + }); + for (const user of users) { + const newRoleIds: Types.ObjectId[] = []; + + for (const role of user.role || []) { + if (role === "superadmin") { + const superAdminRole = roles.find((role) => role.name === "SuperAdmin"); + newRoleIds.push(superAdminRole!._id); + } + if (role === "admin") { + const managerRole = roles.find((role) => role.name === "Manager"); + newRoleIds.push(managerRole!._id); + } + if (role === "user") { + const memberRole = roles.find((role) => role.name === "Member"); + newRoleIds.push(memberRole!._id); + } + } + const merged = [...new Set([...user.roles, ...newRoleIds])]; + user.roles = merged; + user.version = 2; + await user.save(); + } + + return true; + } catch (err) { + console.error("Migration error:", err); + return false; + } +} + +export { migrateUsers }; diff --git a/server/src/db/mongo/migration/index.js b/server/src/db/mongo/migration/index.js deleted file mode 100644 index 4bf56895a..000000000 --- a/server/src/db/mongo/migration/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js"; - -const runMigrations = async () => { - await migrateStatusWindowThreshold(); -}; - -export { runMigrations }; diff --git a/server/src/db/mongo/migration/index.ts b/server/src/db/mongo/migration/index.ts new file mode 100644 index 000000000..293e0905f --- /dev/null +++ b/server/src/db/mongo/migration/index.ts @@ -0,0 +1,25 @@ +import { migrateStatusWindowThreshold, MIGRATION_NAME as MIGRATION_0001 } from "./0001_migrateStatusWindowThreshold.js"; +import { migrateUsers, MIGRATION_NAME as MIGRATION_0002 } from "./0002_migrateUsers.js"; +import { Migration, IMigration } from "../../models/index.js"; + +const runMigrations = async () => { + const migrations = [ + { name: MIGRATION_0001, migration: migrateStatusWindowThreshold }, + { name: MIGRATION_0002, migration: migrateUsers }, + ]; + + const migrationNames = migrations.map((m) => m.name); + + const appliedMigrations = await Migration.find({ name: { $in: migrationNames }, success: true }); + + const migrationsToRun = migrations.filter((m) => !appliedMigrations.some((am: IMigration) => am.name === m.name)); + + for (const { name, migration } of migrationsToRun) { + console.log(`Running migration: ${name}`); + const success = await migration(); + await Migration.updateOne({ name }, { $set: { runAt: new Date(), success } }, { upsert: true }); + console.log(`Migration ${name} completed with status: ${success ? "success" : "failure"}`); + } +}; + +export { runMigrations }; diff --git a/server/src/db/mongo/modules/inviteModule.js b/server/src/db/mongo/modules/inviteModule.js index 444e51efd..513524fe5 100755 --- a/server/src/db/mongo/modules/inviteModule.js +++ b/server/src/db/mongo/modules/inviteModule.js @@ -1,17 +1,17 @@ const SERVICE_NAME = "inviteModule"; class InviteModule { - constructor({ InviteToken, crypto, stringService }) { - this.InviteToken = InviteToken; + constructor({ Invite, crypto, stringService }) { + this.Invite = Invite; this.crypto = crypto; this.stringService = stringService; } requestInviteToken = async (userData) => { try { - await this.InviteToken.deleteMany({ email: userData.email }); + await this.Invite.deleteMany({ email: userData.email }); userData.token = this.crypto.randomBytes(32).toString("hex"); - let inviteToken = new this.InviteToken(userData); + let inviteToken = new this.Invite(userData); await inviteToken.save(); return inviteToken; } catch (error) { @@ -23,7 +23,7 @@ class InviteModule { getInviteToken = async (token) => { try { - const invite = await this.InviteToken.findOne({ + const invite = await this.Invite.findOne({ token, }); if (invite === null) { @@ -38,7 +38,7 @@ class InviteModule { }; getInviteTokenAndDelete = async (token) => { try { - const invite = await this.InviteToken.findOneAndDelete({ + const invite = await this.Invite.findOneAndDelete({ token, }); if (invite === null) { diff --git a/server/src/middleware/v2/VerifyToken.ts b/server/src/middleware/v2/VerifyToken.ts new file mode 100644 index 000000000..7dabd51cc --- /dev/null +++ b/server/src/middleware/v2/VerifyToken.ts @@ -0,0 +1,21 @@ +import { Request, Response, NextFunction } from "express"; +import { decode } from "../../utils/JWTUtils.js"; +import ApiError from "../../utils/ApiError.js"; +const verifyToken = (req: Request, res: Response, next: NextFunction) => { + const token = req.cookies.token; + + if (!token) { + const error = new ApiError("No token provided", 401); + return next(error); + } + + try { + const decoded = decode(token); + req.user = decoded; + next(); + } catch (error) { + next(error); + } +}; + +export { verifyToken }; diff --git a/server/src/middleware/verifyTeamAccess.js b/server/src/middleware/verifyTeamAccess.js index 60647bdc2..a548cbf23 100644 --- a/server/src/middleware/verifyTeamAccess.js +++ b/server/src/middleware/verifyTeamAccess.js @@ -3,6 +3,8 @@ const SERVICE_NAME = "verifyTeamAccess"; const verifyTeamAccess = (Model, paramName) => { return async (req, res, next) => { try { + const user = req.user; + console.log(user); const documentId = req.params[paramName]; const doc = await Model.findById(documentId); diff --git a/server/src/routes/v2/auth.ts b/server/src/routes/v2/auth.ts new file mode 100644 index 000000000..5a1b8ad63 --- /dev/null +++ b/server/src/routes/v2/auth.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; + +import express from "express"; +import AuthController from "../../controllers/v2/AuthController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; + +const router = express.Router(); + +class AuthRoutes { + private controller: AuthController; + private router: Router; + constructor(authController: AuthController) { + this.controller = authController; + this.router = Router(); + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/register", this.controller.register); + this.router.post("/register/invite/:token", this.controller.registerWithInvite); + this.router.post("/login", this.controller.login); + this.router.post("/logout", this.controller.logout); + this.router.get("/me", verifyToken, this.controller.me); + }; + + getRouter() { + return this.router; + } +} + +export default AuthRoutes; diff --git a/server/src/service/v2/business/AuthService.ts b/server/src/service/v2/business/AuthService.ts new file mode 100644 index 000000000..ae22b981d --- /dev/null +++ b/server/src/service/v2/business/AuthService.ts @@ -0,0 +1,141 @@ +import bcrypt from "bcryptjs"; +import { User, Role, ITokenizedUser } from "../../../db/models/index.js"; +import ApiError from "../../../utils/ApiError.js"; +import { Types } from "mongoose"; + +export const DEFAULT_ROLES = [ + { + name: "SuperAdmin", + description: "Super admin with all permissions", + permissions: ["*"], + isSystem: true, + }, + { + name: "Admin", + description: "Admin with full permissions", + permissions: ["monitor.*", "users.*"], + isSystem: true, + }, + { + name: "Manager", + description: "Can manage users", + permissions: ["users.create", "users.update", "monitors.*"], + isSystem: true, + }, + { + name: "Member", + description: "Basic team member", + permissions: ["users.update", "monitors.create", "monitors.view", "monitors.update"], + isSystem: true, + }, +]; + +export type RegisterData = { + email: string; + firstName: string; + lastName: string; + password: string; + roles?: Types.ObjectId[]; // Optional roles for invite-based registration +}; + +export type LoginData = { + email: string; + password: string; +}; + +export type AuthResult = ITokenizedUser; + +export interface IAuthService { + register(signupData: RegisterData): Promise; + registerWithInvite(signupData: RegisterData): Promise; + login(loginData: LoginData): Promise; +} + +class AuthService implements IAuthService { + constructor() {} + + async register(signupData: RegisterData): Promise { + const userCount = await User.countDocuments(); + + if (userCount > 0) { + throw new Error("Registration is closed. Please request an invite."); + } + + const { email, firstName, lastName, password } = signupData; + + // Create all default roles + const rolePromises = DEFAULT_ROLES.map((roleData) => + new Role({ + ...roleData, + }).save() + ); + const roles = await Promise.all(rolePromises); + + // Find admin role and assign to first user + const superAdminRole = roles.find((role) => role.name === "SuperAdmin"); + + const user = new User({ + email, + firstName, + lastName, + password, + roles: [superAdminRole!._id], + }); + + const savedUser = await user.save(); + return { + sub: savedUser._id.toString(), + roles: savedUser.roles.map((role) => role.toString()), + }; + } + + async registerWithInvite(signupData: RegisterData): Promise { + const { email, firstName, lastName, password, roles } = signupData; + + const user = new User({ + email, + firstName, + lastName, + password, + roles: roles || [], + }); + try { + const savedUser = await user.save(); + return { + sub: savedUser._id.toString(), + roles: savedUser.roles.map((role) => role.toString()), + }; + } catch (error: any) { + if (error?.code === 11000) { + const dupError = new ApiError("Email already in use", 409); + dupError.stack = error?.stack; + throw dupError; + } + throw error; + } + } + + async login(loginData: LoginData): Promise { + const { email, password } = loginData; + + // Find user by email + const user = await User.findOne({ email }); + + if (!user) { + throw new Error("Invalid email or password"); + } + + // Check password + const isPasswordValid = await bcrypt.compare(password, user.passwordHash); + if (!isPasswordValid) { + throw new Error("Invalid email or password"); + } + + return { + sub: user._id.toString(), + roles: user.roles.map((role) => role.toString()), + }; + } +} + +export default AuthService; diff --git a/server/src/service/v2/business/InviteService.ts b/server/src/service/v2/business/InviteService.ts new file mode 100644 index 000000000..f8bd9e2db --- /dev/null +++ b/server/src/service/v2/business/InviteService.ts @@ -0,0 +1,61 @@ +import crypto from "node:crypto"; +import { ITokenizedUser, IInvite, Invite } from "../../../db/models/index.js"; +import ApiError from "../../../utils/ApiError.js"; + +export interface IInviteService { + create: (tokenizedUser: ITokenizedUser, invite: IInvite) => Promise<{ token: string }>; + getAll: () => Promise; + get: (tokenHash: string) => Promise; + delete: (id: string) => Promise; +} + +class InviteService implements IInviteService { + constructor() {} + + create = async (tokenizedUser: ITokenizedUser, inviteData: IInvite) => { + const token = crypto.randomBytes(32).toString("hex"); + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + try { + const invite = await Invite.create({ + ...inviteData, + tokenHash, + createdBy: tokenizedUser.sub, + updatedBy: tokenizedUser.sub, + }); + if (!invite) { + throw new ApiError("Failed to create invite", 500); + } + return { token }; + } catch (error: any) { + if (error?.code === 11000) { + const dupError = new ApiError("Invite with this email already exists", 409); + dupError.stack = error?.stack; + throw dupError; + } + throw error; + } + }; + + get = async (token: string) => { + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + const invite = await Invite.findOne({ tokenHash }); + if (!invite) { + throw new ApiError("Invite not found", 404); + } + return invite; + }; + + getAll = async () => { + return Invite.find(); + }; + + delete = async (id: string) => { + const result = await Invite.deleteOne({ _id: id }); + if (!result.deletedCount) { + throw new ApiError("Invite not found", 404); + } + return result.deletedCount === 1; + }; +} + +export default InviteService; diff --git a/server/src/utils/ApiError.ts b/server/src/utils/ApiError.ts new file mode 100644 index 000000000..313d01c2a --- /dev/null +++ b/server/src/utils/ApiError.ts @@ -0,0 +1,15 @@ +class ApiError extends Error { + public status: number; + + constructor(message: string, status: number = 500) { + super(message); + this.status = status; + this.name = this.constructor.name; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export default ApiError; diff --git a/server/src/utils/JWTUtils.ts b/server/src/utils/JWTUtils.ts new file mode 100644 index 000000000..4eebb78a0 --- /dev/null +++ b/server/src/utils/JWTUtils.ts @@ -0,0 +1,22 @@ +import jwt from "jsonwebtoken"; +import { AuthResult } from "../service/v2/business/AuthService.js"; + +const encode = (data: AuthResult): string => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not defined"); + } + const token = jwt.sign(data, secret, { expiresIn: "99d" }); + return token; +}; + +const decode = (token: string): AuthResult => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not defined"); + } + const decoded = jwt.verify(token, secret) as AuthResult; + return decoded; +}; + +export { encode, decode };