diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index 7a4c9d83c8d..c40a594b9ee 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -20,6 +20,7 @@ import StatusPageAPI from "CommonServer/API/StatusPageAPI"; import StatusPageDomainAPI from "CommonServer/API/StatusPageDomainAPI"; import StatusPageSubscriberAPI from "CommonServer/API/StatusPageSubscriberAPI"; import UserCallAPI from "CommonServer/API/UserCallAPI"; +import UserTwoFactorAuthAPI from "CommonServer/API/UserTwoFactorAuthAPI"; // User Notification methods. import UserEmailAPI from "CommonServer/API/UserEmailAPI"; import UserNotificationLogTimelineAPI from "CommonServer/API/UserOnCallLogTimelineAPI"; @@ -1100,6 +1101,10 @@ const BaseAPIFeatureSet: FeatureSet = { new UserNotificationLogTimelineAPI().getRouter(), ); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserCallAPI().getRouter()); + app.use( + `/${APP_NAME.toLocaleLowerCase()}`, + new UserTwoFactorAuthAPI().getRouter(), + ); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserEmailAPI().getRouter()); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserSMSAPI().getRouter()); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new Ingestor().getRouter()); diff --git a/Common/Types/Icon/IconProp.ts b/Common/Types/Icon/IconProp.ts index e590a640017..d1ebd908cd0 100644 --- a/Common/Types/Icon/IconProp.ts +++ b/Common/Types/Icon/IconProp.ts @@ -6,6 +6,7 @@ enum IconProp { TableCells = "TableCells", Layout = "Layout", Compass = "Compass", + ShieldCheck = "ShieldCheck", User = "User", Disc = "Disc", Settings = "Settings", diff --git a/CommonServer/API/UserTwoFactorAuthAPI.ts b/CommonServer/API/UserTwoFactorAuthAPI.ts new file mode 100644 index 00000000000..55c75cd85df --- /dev/null +++ b/CommonServer/API/UserTwoFactorAuthAPI.ts @@ -0,0 +1,77 @@ +import ObjectID from "Common/Types/ObjectID"; +import UserMiddleware from "../Middleware/UserAuthorization"; +import UserTwoFactorAuthService, { + Service as UserTwoFactorAuthServiceType, +} from "../Services/UserTwoFactorAuthService"; +import { ExpressRequest, ExpressResponse, NextFunction, OneUptimeRequest } from "../Utils/Express"; +import BaseAPI from "./BaseAPI"; +import UserTwoFactorAuth from "Model/Models/UserTwoFactorAuth"; +import BadDataException from "Common/Types/Exception/BadDataException"; +import TwoFactorAuth from "../Utils/TwoFactorAuth"; +import Response from "../Utils/Response"; + +export default class UserTwoFactorAuthAPI extends BaseAPI< + UserTwoFactorAuth, + UserTwoFactorAuthServiceType +> { + public constructor() { + super(UserTwoFactorAuth, UserTwoFactorAuthService); + + this.router.post( + `${new this.entityType().getCrudApiPath()?.toString()}/validate`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + + const userTwoFactorAuth: UserTwoFactorAuth | null = await UserTwoFactorAuthService.findOneById({ + id: new ObjectID(req.body['id']), + select: { + twoFactorSecret: true, + userId: true, + }, + props: { + isRoot: true, + }, + }); + + if(!userTwoFactorAuth) { + throw new BadDataException("Two factor auth not found"); + } + + if(userTwoFactorAuth.userId?.toString() !== (req as OneUptimeRequest).userAuthorization?.userId.toString()) { + throw new BadDataException("Two factor auth not found"); + } + + + const isValid: boolean = TwoFactorAuth.verifyToken( + { + secret: userTwoFactorAuth.twoFactorSecret || "", + token: req.body['code'] || "", + } + ); + + if(!isValid) { + throw new BadDataException("Invalid code"); + } + + // update this 2fa code as verified + + await UserTwoFactorAuthService.updateOneById({ + id: userTwoFactorAuth.id!, + data: { + isVerified: true, + }, + props: { + isRoot: true, + } + }); + + return Response.sendEmptySuccessResponse(req, res); + + } catch (err) { + next(err); + } + }, + ); + } +} diff --git a/CommonServer/Infrastructure/Postgres/SchemaMigrations/1722031205897-MigrationName.ts b/CommonServer/Infrastructure/Postgres/SchemaMigrations/1722031205897-MigrationName.ts new file mode 100644 index 00000000000..c907c32ca3b --- /dev/null +++ b/CommonServer/Infrastructure/Postgres/SchemaMigrations/1722031205897-MigrationName.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1722031205897 implements MigrationInterface { + public name = "MigrationName1722031205897"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "UserTwoFactorAuth" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "version" integer NOT NULL, "name" character varying(100) NOT NULL, "twoFactorSecret" text NOT NULL, "twoFactorOtpUrl" text NOT NULL, "isVerified" boolean NOT NULL DEFAULT false, "deletedByUserId" uuid, "userId" uuid, CONSTRAINT "PK_1e248beb4011dcab4bd5ca73fc1" PRIMARY KEY ("_id"))`, + ); + await queryRunner.query( + `ALTER TABLE "User" ADD "enableTwoFactorAuth" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "UserTwoFactorAuth" ADD CONSTRAINT "FK_6e0fdd6ab0cee72277efc2bbab4" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "UserTwoFactorAuth" ADD CONSTRAINT "FK_3a7c46ce8b2f60e0801a0aaeaa2" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "UserTwoFactorAuth" DROP CONSTRAINT "FK_3a7c46ce8b2f60e0801a0aaeaa2"`, + ); + await queryRunner.query( + `ALTER TABLE "UserTwoFactorAuth" DROP CONSTRAINT "FK_6e0fdd6ab0cee72277efc2bbab4"`, + ); + await queryRunner.query( + `ALTER TABLE "User" DROP COLUMN "enableTwoFactorAuth"`, + ); + await queryRunner.query(`DROP TABLE "UserTwoFactorAuth"`); + } +} diff --git a/CommonServer/Infrastructure/Postgres/SchemaMigrations/Index.ts b/CommonServer/Infrastructure/Postgres/SchemaMigrations/Index.ts index 6328a206a7f..515adae193a 100644 --- a/CommonServer/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/CommonServer/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -35,6 +35,7 @@ import { MigrationName1721152139648 } from "./1721152139648-MigrationName"; import { MigrationName1721159743714 } from "./1721159743714-MigrationName"; import { MigrationName1721754545771 } from "./1721754545771-MigrationName"; import { MigrationName1721779190475 } from "./1721779190475-MigrationName"; +import { MigrationName1722031205897 } from "./1722031205897-MigrationName"; export default [ InitialMigration, @@ -74,4 +75,5 @@ export default [ MigrationName1721159743714, MigrationName1721754545771, MigrationName1721779190475, + MigrationName1722031205897, ]; diff --git a/CommonServer/Services/Index.ts b/CommonServer/Services/Index.ts index b8780a873e3..3f31d2e7f69 100644 --- a/CommonServer/Services/Index.ts +++ b/CommonServer/Services/Index.ts @@ -120,6 +120,7 @@ import UserNotificationSettingService from "./UserNotificationSettingService"; import UserOnCallLogService from "./UserOnCallLogService"; import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService"; import UserService from "./UserService"; +import UserTwoFactorAuthService from "./UserTwoFactorAuthService"; import UserSmsService from "./UserSmsService"; import WorkflowLogService from "./WorkflowLogService"; // Workflows. @@ -235,6 +236,7 @@ const services: Array = [ UserOnCallLogService, UserOnCallLogTimelineService, UserSmsService, + UserTwoFactorAuthService, WorkflowLogService, WorkflowService, diff --git a/CommonServer/Services/UserTwoFactorAuthService.ts b/CommonServer/Services/UserTwoFactorAuthService.ts new file mode 100644 index 00000000000..2d5e1a72eac --- /dev/null +++ b/CommonServer/Services/UserTwoFactorAuthService.ts @@ -0,0 +1,58 @@ +import PostgresDatabase from "../Infrastructure/PostgresDatabase"; +import CreateBy from "../Types/Database/CreateBy"; +import { OnCreate } from "../Types/Database/Hooks"; +import DatabaseService from "./DatabaseService"; +import Model from "Model/Models/UserTwoFactorAuth"; +import TwoFactorAuth from '../Utils/TwoFactorAuth'; +import UserService from "./UserService"; +import BadDataException from "Common/Types/Exception/BadDataException"; +import User from "Model/Models/User"; + +export class Service extends DatabaseService { + public constructor(postgresDatabase?: PostgresDatabase) { + super(Model, postgresDatabase); + } + + protected override async onBeforeCreate(createBy: CreateBy): Promise> { + + if(!createBy.props.userId) { + throw new BadDataException("User id is required"); + } + + createBy.data.userId = createBy.props.userId; + + const user: User | null = await UserService.findOneById({ + id: createBy.data.userId, + props: { + isRoot: true, + }, + select: { + email: true, + } + }); + + + if(!user) { + throw new BadDataException("User not found"); + } + + if(!user.email) { + throw new BadDataException("User email is required"); + } + + createBy.data.twoFactorSecret = TwoFactorAuth.generateSecret(); + createBy.data.twoFactorOtpUrl = TwoFactorAuth.generateUri({ + secret: createBy.data.twoFactorSecret, + userEmail: user.email + }); + createBy.data.isVerified = false; + + return { + createBy: createBy, + carryForward: {} + }; + + } +} + +export default new Service(); diff --git a/CommonServer/Utils/TwoFactorAuth.ts b/CommonServer/Utils/TwoFactorAuth.ts new file mode 100644 index 00000000000..d74b0d345ab --- /dev/null +++ b/CommonServer/Utils/TwoFactorAuth.ts @@ -0,0 +1,62 @@ +import Email from 'Common/Types/Email'; +import speakeasy from 'speakeasy'; + + +/** + * Utility class for handling two-factor authentication. + */ +export default class TwoFactorAuth { + /** + * Generates a random secret key for two-factor authentication. + * @returns The generated secret key. + */ + public static generateSecret(): string { + return speakeasy.generateSecret().base32; + } + + /** + * Verifies if a given token matches the provided secret key. + * @param data - The data object containing the secret key and token. + * @returns A boolean indicating whether the token is valid or not. + */ + public static verifyToken(data: { + secret: string, + token: string + }): boolean { + const { secret, token } = data; + return speakeasy.totp.verify({ + secret, + encoding: 'base32', + token, + }); + } + + /** + * Generates a time-based one-time password (TOTP) token using the provided secret key. + * @param secret - The secret key used to generate the token. + * @returns The generated TOTP token. + */ + public static generateToken(secret: string): string { + return speakeasy.totp({ + secret, + encoding: 'base32', + }); + } + + /** + * Generates a URI for the given secret key and user email, which can be used to set up two-factor authentication. + * @param data - The data object containing the secret key and user email. + * @returns The generated URI for setting up two-factor authentication. + */ + public static generateUri(data: { + secret: string, + userEmail: Email + }): string { + const { secret, userEmail } = data; + return speakeasy.otpauthURL({ + secret, + label: userEmail.toString(), + issuer: "OneUptime", + }); + } +} \ No newline at end of file diff --git a/CommonServer/package-lock.json b/CommonServer/package-lock.json index 1c363b9c8fc..58c0a8b1a76 100644 --- a/CommonServer/package-lock.json +++ b/CommonServer/package-lock.json @@ -23,12 +23,12 @@ "@opentelemetry/sdk-node": "^0.48.0", "@opentelemetry/sdk-trace-node": "^1.21.0", "@types/crypto-js": "^4.2.2", + "@types/speakeasy": "^2.0.10", "acme-client": "^5.3.0", "airtable": "^0.12.2", "bullmq": "^5.3.3", "Common": "file:../Common", - - + "CommonProject": "file:../CommonProject", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cron-parser": "^4.8.1", @@ -46,6 +46,7 @@ "pg": "^8.7.3", "redis-semaphore": "^5.5.1", "socket.io": "^4.7.4", + "speakeasy": "^2.0.0", "stripe": "^10.17.0", "twilio": "^4.22.0", "typeorm": "^0.3.20", @@ -80,7 +81,7 @@ "json5": "^2.2.3", "moment": "^2.30.1", "moment-timezone": "^0.5.45", - "posthog-js": "^1.139.3", + "posthog-js": "^1.139.6", "reflect-metadata": "^0.2.2", "slugify": "^1.6.5", "typeorm": "^0.3.20", @@ -4666,11 +4667,9 @@ "license": "Apache-2.0", "dependencies": { "Common": "file:../Common", - "Model": "file:../Model" }, "devDependencies": { - "@faker-js/faker": "^8.0.2", "@types/jest": "^27.5.2", "@types/node": "^17.0.22", "jest": "^27.5.1", @@ -4683,7 +4682,6 @@ "license": "Apache-2.0", "dependencies": { "Common": "file:../Common", - "typeorm": "^0.3.20" }, "devDependencies": { @@ -13371,6 +13369,15 @@ "version": "1.0.5", "license": "MIT" }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "dev": true, @@ -13706,6 +13713,12 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "funding": [ @@ -17639,6 +17652,18 @@ "source-map": "^0.6.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "license": "ISC", diff --git a/CommonServer/package.json b/CommonServer/package.json index adcd1e6d05e..0e37c8eda6a 100644 --- a/CommonServer/package.json +++ b/CommonServer/package.json @@ -28,6 +28,7 @@ "@opentelemetry/sdk-node": "^0.48.0", "@opentelemetry/sdk-trace-node": "^1.21.0", "@types/crypto-js": "^4.2.2", + "@types/speakeasy": "^2.0.10", "acme-client": "^5.3.0", "airtable": "^0.12.2", "bullmq": "^5.3.3", @@ -45,12 +46,12 @@ "jsonwebtoken": "^9.0.0", "marked": "^12.0.2", "Model": "file:../Model", - "node-cron": "^3.0.3", "nodemailer": "^6.9.10", "pg": "^8.7.3", "redis-semaphore": "^5.5.1", "socket.io": "^4.7.4", + "speakeasy": "^2.0.0", "stripe": "^10.17.0", "twilio": "^4.22.0", "typeorm": "^0.3.20", diff --git a/CommonUI/package-lock.json b/CommonUI/package-lock.json index c4d19e5c74a..1a5b45cc84e 100644 --- a/CommonUI/package-lock.json +++ b/CommonUI/package-lock.json @@ -23,6 +23,7 @@ "@opentelemetry/sdk-trace-web": "^1.23.0", "@opentelemetry/semantic-conventions": "^1.25.0", "@tippyjs/react": "^4.2.6", + "@types/qrcode": "^1.5.5", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", "Common": "file:../Common", @@ -34,6 +35,7 @@ "Model": "file:../Model", "moment-timezone": "^0.5.45", "prop-types": "^15.8.1", + "qrcode": "^1.5.3", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-big-calendar": "^1.13.0", @@ -4672,7 +4674,6 @@ "Model": "file:../Model" }, "devDependencies": { - "@faker-js/faker": "^8.0.2", "@types/jest": "^27.5.2", "@types/node": "^17.0.22", "jest": "^27.5.1", @@ -5253,21 +5254,6 @@ "dev": true, "license": "MIT" }, - "../CommonProject/node_modules/@faker-js/faker": { - "version": "8.3.1", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" - } - }, "../CommonProject/node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "dev": true, @@ -15277,6 +15263,15 @@ "version": "15.7.11", "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.2.45", "license": "MIT", @@ -15489,7 +15484,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15497,7 +15491,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -15781,7 +15774,6 @@ }, "node_modules/camelcase": { "version": "5.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -15925,7 +15917,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -15936,7 +15927,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -16270,6 +16260,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.4.3", "dev": true, @@ -16403,6 +16402,12 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, @@ -16445,7 +16450,12 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, + "license": "MIT" + }, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", "license": "MIT" }, "node_modules/engine.io-client": { @@ -16687,7 +16697,6 @@ }, "node_modules/find-up": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -16789,7 +16798,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -17340,7 +17348,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18716,7 +18723,6 @@ }, "node_modules/locate-path": { "version": "5.0.0", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -19815,7 +19821,6 @@ }, "node_modules/p-locate": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -19826,7 +19831,6 @@ }, "node_modules/p-locate/node_modules/p-limit": { "version": "2.3.0", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -19840,7 +19844,6 @@ }, "node_modules/p-try": { "version": "2.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -19909,7 +19912,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -19977,6 +19979,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "dev": true, @@ -20077,6 +20088,90 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", + "integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "encode-utf8": "^1.0.3", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/querystringify": { "version": "2.2.0", "dev": true, @@ -20554,7 +20649,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -20572,6 +20666,12 @@ "node": ">=8.6.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "dev": true, @@ -20678,6 +20778,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.1.1", "dev": true, @@ -20861,7 +20967,6 @@ }, "node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -20874,7 +20979,6 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -21549,6 +21653,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.13", "dev": true, diff --git a/CommonUI/package.json b/CommonUI/package.json index e99484a6edb..c5ffb76b206 100644 --- a/CommonUI/package.json +++ b/CommonUI/package.json @@ -28,11 +28,11 @@ "@opentelemetry/sdk-trace-web": "^1.23.0", "@opentelemetry/semantic-conventions": "^1.25.0", "@tippyjs/react": "^4.2.6", + "@types/qrcode": "^1.5.5", "@types/react-highlight": "^0.12.8", "@types/react-syntax-highlighter": "^15.5.13", "Common": "file:../Common", "CommonProject": "file:../CommonProject", - "formik": "^2.4.6", "history": "^5.3.0", "jwt-decode": "^4.0.0", @@ -40,6 +40,7 @@ "Model": "file:../Model", "moment-timezone": "^0.5.45", "prop-types": "^15.8.1", + "qrcode": "^1.5.3", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-big-calendar": "^1.13.0", diff --git a/CommonUI/src/Components/Icon/Icon.tsx b/CommonUI/src/Components/Icon/Icon.tsx index 3ff0d55e954..d19043542ad 100644 --- a/CommonUI/src/Components/Icon/Icon.tsx +++ b/CommonUI/src/Components/Icon/Icon.tsx @@ -138,6 +138,14 @@ const Icon: FunctionComponent = ({ d="m3 3 8.735 8.735m0 0a.374.374 0 1 1 .53.53m-.53-.53.53.53m0 0L21 21M14.652 9.348a3.75 3.75 0 0 1 0 5.304m2.121-7.425a6.75 6.75 0 0 1 0 9.546m2.121-11.667c3.808 3.807 3.808 9.98 0 13.788m-9.546-4.242a3.733 3.733 0 0 1-1.06-2.122m-1.061 4.243a6.75 6.75 0 0 1-1.625-6.929m-.496 9.05c-3.068-3.067-3.664-7.67-1.79-11.334M12 12h.008v.008H12V12Z" />, ); + } else if (icon === IconProp.ShieldCheck) { + return getSvgWrapper( + , + ); } else if (icon === IconProp.EyeSlash) { return getSvgWrapper( = ( + props: ComponentProps, +): ReactElement => { + + const [error, setError] = React.useState(null); + const [data, setData] = React.useState(null); + + useEffect(() => { + QRCode.toDataURL(props.text, function (err: Error | null | undefined, data: string) { + if(err) { + setError(err.message); + return; + } + setData(data); + }) + }, [props.text]); + + if(error){ + return + } + + return ( + {props.text} + ); +}; + +export default QRCodeElement; diff --git a/Dashboard/src/App.tsx b/Dashboard/src/App.tsx index 21311a970f9..32c43bf72bd 100644 --- a/Dashboard/src/App.tsx +++ b/Dashboard/src/App.tsx @@ -54,6 +54,7 @@ import { useParams, } from "react-router-dom"; import useAsyncEffect from "use-async-effect"; +import UseTwoFactorAuth from "./Pages/Global/UserProfile/TwoFactorAuth"; const App: () => JSX.Element = () => { Navigation.setNavigateHook(useNavigate()); @@ -407,6 +408,16 @@ const App: () => JSX.Element = () => { } /> + + } + /> + JSX.Element = (): ReactElement => { return ( + JSX.Element = (): ReactElement => { icon={IconProp.Info} /> + + + + JSX.Element = (): ReactElement => { + ); }; diff --git a/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx b/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx new file mode 100644 index 00000000000..c3de7fcd14c --- /dev/null +++ b/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx @@ -0,0 +1,215 @@ +import ModelTable from "CommonUI/src/Components/ModelTable/ModelTable"; +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import PageComponentProps from "../../PageComponentProps"; +import SideMenu from "./SideMenu"; +import Route from "Common/Types/API/Route"; +import Page from "CommonUI/src/Components/Page/Page"; +import React, { FunctionComponent, ReactElement } from "react"; +import User from "CommonUI/src/Utils/User"; +import UserTwoFactorAuth from "Model/Models/UserTwoFactorAuth"; +import { ButtonStyleType } from "CommonUI/src/Components/Button/Button"; +import IconProp from "Common/Types/Icon/IconProp"; +import FieldType from "CommonUI/src/Components/Types/FieldType"; +import FormFieldSchemaType from "CommonUI/src/Components/Forms/Types/FormFieldSchemaType"; +import BasicFormModal from "CommonUI/src/Components/FormModal/BasicFormModal"; +import QRCodeElement from "CommonUI/src/Components/QR/QR"; +import { JSONObject } from "Common/Types/JSON"; +import HTTPResponse from "Common/Types/API/HTTPResponse"; +import EmptyResponseData from "Common/Types/API/EmptyResponse"; +import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse"; +import API from "CommonUI/src/Utils/API/API"; +import { APP_API_URL } from "CommonUI/src/Config"; +import URL from "Common/Types/API/URL"; + +const Home: FunctionComponent = (): ReactElement => { + + + const [selectedTwoFactorAuth, setSelectedTwoFactorAuth] = React.useState(null); + const [showVerificationModal, setShowVerificationModal] = React.useState(false); + const [verificationError, setVerificationError] = React.useState(null); + const [verificationLoading, setVerificationLoading] = React.useState(false); + + const [tableRefreshToggle, setTableRefreshToggle] = React.useState(false); + + + return ( + } + > +
+ + modelType={UserTwoFactorAuth} + name="Two Factor Authentication" + id="two-factor-auth-table" + isDeleteable={true} + refreshToggle={tableRefreshToggle} + filters={[]} + query={{ + userId: User.getUserId(), + }} + isEditable={true} + showRefreshButton={true} + isCreateable={true} + isViewable={false} + cardProps={{ + title: "Two Factor Authentication", + description: "Manage your two factor authentication settings here.", + }} + noItemsMessage={"No two factor authentication found."} + singularName="Two Factor Authentication" + pluralName="Two Factor Authentications" + actionButtons={[ + { + title: "Verify", + buttonStyleType: ButtonStyleType.NORMAL, + icon: IconProp.Check, + isVisible: (item: UserTwoFactorAuth) => !item.isVerified, + onClick: async ( + item: UserTwoFactorAuth, + onCompleteAction: VoidFunction, + ) => { + setSelectedTwoFactorAuth(item); + setShowVerificationModal(true); + onCompleteAction(); + }, + }, + ]} + formFields={[ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + }, + ]} + selectMoreFields={{ + twoFactorOtpUrl: true, + }} + deleteButtonText="Reject" + columns={[ + { + field: { + name: true, + }, + title: "Name", + type: FieldType.Text, + selectedProperty: "name", + }, + { + field: { + isVerified: true, + }, + title: "Is Verified?", + type: FieldType.Boolean, + }, + ]} + /> + {showVerificationModal && selectedTwoFactorAuth ? ( + { + return ; + } + }, + { + field: { + code: true, + }, + title: "Code", + description: "Please enter the code from your authenticator app.", + fieldType: FormFieldSchemaType.Text, + required: true, + }, + ], + }} + submitButtonText={"Validate"} + onClose={() => { + setShowVerificationModal(false); + setVerificationError(null); + setSelectedTwoFactorAuth(null); + }} + isLoading={verificationLoading} + onSubmit={async (values: JSONObject) => { + try { + setVerificationLoading(true); + setVerificationError(""); + + // test CallSMS config + const response: + | HTTPResponse + | HTTPErrorResponse = await API.post( + URL.fromString(APP_API_URL.toString()).addRoute( + `/user-two-factor-auth/validate`, + ), + { + code: values['code'], + id: selectedTwoFactorAuth.id?.toString(), + }, + ); + if (response.isSuccess()) { + setShowVerificationModal(false); + setVerificationError(null); + setSelectedTwoFactorAuth(null); + setVerificationLoading(false); + } + + if (response instanceof HTTPErrorResponse) { + throw response; + } + + setTableRefreshToggle(!tableRefreshToggle); + + + } catch (err) { + setVerificationError(API.getFriendlyMessage(err)); + setVerificationLoading(false); + } + + setVerificationLoading(false); + + }} + /> + ) : ( + <> + )} + + +
+
+ ); +}; + +export default Home; diff --git a/Dashboard/src/Utils/PageMap.ts b/Dashboard/src/Utils/PageMap.ts index ab417252010..c4e7ff1a5a8 100644 --- a/Dashboard/src/Utils/PageMap.ts +++ b/Dashboard/src/Utils/PageMap.ts @@ -260,6 +260,7 @@ enum PageMap { USER_PROFILE_OVERVIEW = "USER_PROFILE_OVERVIEW", USER_PROFILE_PASSWORD = "USER_PROFILE_PASSWORD", USER_PROFILE_PICTURE = "USER_PROFILE_PICTURE", + USER_TWO_FACTOR_AUTH = "USER_TWO_FACTOR_AUTH", NEW_INCIDENTS = "NEW_INCIDENTS", PROJECT_INVITATIONS = "PROJECT_INVITATIONS", diff --git a/Dashboard/src/Utils/RouteMap.ts b/Dashboard/src/Utils/RouteMap.ts index c6721424914..3dbad0dd545 100644 --- a/Dashboard/src/Utils/RouteMap.ts +++ b/Dashboard/src/Utils/RouteMap.ts @@ -341,6 +341,9 @@ const RouteMap: Dictionary = { [PageMap.USER_PROFILE_PASSWORD]: new Route( `/dashboard/user-profile/password-management`, ), + [PageMap.USER_TWO_FACTOR_AUTH]: new Route( + `/dashboard/user-profile/two-factor-auth`, + ), [PageMap.USER_PROFILE_PICTURE]: new Route( `/dashboard/user-profile/profile-picture`, ), diff --git a/Model/Models/Index.ts b/Model/Models/Index.ts index 9608c9315c9..34a0da7eabe 100644 --- a/Model/Models/Index.ts +++ b/Model/Models/Index.ts @@ -131,6 +131,8 @@ import ServiceCatalogDependency from "./ServiceCatalogDependency"; import ServiceCatalogMonitor from "./ServiceCatalogMonitor"; import ServiceCatalogTelemetryService from "./ServiceCatalogTelemetryService"; +import UserTwoFactorAuth from "./UserTwoFactorAuth"; + export default [ User, Probe, @@ -281,4 +283,6 @@ export default [ ProbeOwnerTeam, ProbeOwnerUser, + + UserTwoFactorAuth, ]; diff --git a/Model/Models/User.ts b/Model/Models/User.ts index 3673119e956..f3a2058fe17 100644 --- a/Model/Models/User.ts +++ b/Model/Models/User.ts @@ -700,6 +700,23 @@ class User extends UserModel { transformer: ObjectID.getDatabaseTransformer(), }) public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + title: "Two Factor Auth Enabled", + description: "Is two factor authentication enabled?", + isDefaultValueColumn: true, + type: TableColumnType.Boolean, + }) + @Column({ + type: ColumnType.Boolean, + default: false, + }) + public enableTwoFactorAuth?: boolean = undefined; } export default User; diff --git a/Model/Models/UserTwoFactorAuth.ts b/Model/Models/UserTwoFactorAuth.ts new file mode 100644 index 00000000000..0a68057057f --- /dev/null +++ b/Model/Models/UserTwoFactorAuth.ts @@ -0,0 +1,208 @@ +import BaseModel from "Common/Models/BaseModel"; +import User from "./User"; +import Route from "Common/Types/API/Route"; +import AllowAccessIfSubscriptionIsUnpaid from "Common/Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid"; +import ColumnAccessControl from "Common/Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "Common/Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "Common/Types/Database/ColumnLength"; +import ColumnType from "Common/Types/Database/ColumnType"; +import CrudApiEndpoint from "Common/Types/Database/CrudApiEndpoint"; +import CurrentUserCanAccessRecordBy from "Common/Types/Database/CurrentUserCanAccessRecordBy"; +import EnableDocumentation from "Common/Types/Database/EnableDocumentation"; +import TableColumn from "Common/Types/Database/TableColumn"; +import TableColumnType from "Common/Types/Database/TableColumnType"; +import TableMetadata from "Common/Types/Database/TableMetadata"; +import IconProp from "Common/Types/Icon/IconProp"; +import ObjectID from "Common/Types/ObjectID"; +import Permission from "Common/Types/Permission"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +@EnableDocumentation({ + isMasterAdminApiDocs: true, +}) +@AllowAccessIfSubscriptionIsUnpaid() +@TableAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + delete: [Permission.CurrentUser], + update: [Permission.CurrentUser], +}) +@CrudApiEndpoint(new Route("/user-two-factor-auth")) +@Entity({ + name: "UserTwoFactorAuth", +}) +@TableMetadata({ + tableName: "UserTwoFactorAuth", + singularName: "Two Factor Auth", + pluralName: "Two Factor Auth", + icon: IconProp.ShieldCheck, + tableDescription: "Two Factor Authentication for users", +}) +@CurrentUserCanAccessRecordBy("userId") +class UserTwoFactorAuth extends BaseModel { + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Two Factor Auth Name", + description: "Name of the two factor authentication", + }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public name?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Two Factor Auth Secret", + description: "Secret of the two factor authentication", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public twoFactorSecret?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Two Factor Auth OTP URL", + description: "OTP URL of the two factor authentication", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public twoFactorOtpUrl?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + type: TableColumnType.Boolean, + canReadOnRelationQuery: true, + title: "Is Verified", + isDefaultValueColumn: true, + description: + "Is this two factor authentication verified and validated (has user entered the tokent to verify it)", + }) + @Column({ + type: ColumnType.Boolean, + nullable: false, + default: false, + }) + public isVerified?: boolean = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + manyToOneRelationColumn: "userId", + type: TableColumnType.Entity, + title: "User", + description: "Relation to User who owns this two factor authentication", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "userId" }) + public user?: User = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public userId?: ObjectID = undefined; +} + +export default UserTwoFactorAuth;