Skip to content

Commit

Permalink
feat: Add support for two-factor authentication in user profile
Browse files Browse the repository at this point in the history
This commit adds support for two-factor authentication in the user profile. It includes the necessary code changes to enable the "Two Factor Auth Enabled" feature in the User model, as well as the addition of the "USER_TWO_FACTOR_AUTH" page in the PageMap and the corresponding route in the RouteMap. The UserTwoFactorAuth model, service, and controller have also been added to handle the logic and API endpoints related to two-factor authentication.

See the following files for more details:
- Common/Types/Icon/IconProp.ts
- Dashboard/src/Utils/PageMap.ts
- Dashboard/src/Utils/RouteMap.ts
- Model/Models/Index.ts
- Model/Models/User.ts
- CommonServer/Services/Index.ts
- CommonServer/package.json
- CommonUI/package.json
- CommonServer/Infrastructure/Postgres/SchemaMigrations/Index.ts
- Dashboard/src/App.tsx
  • Loading branch information
simlarsen committed Jul 27, 2024
1 parent 04f1cfe commit bf031f6
Show file tree
Hide file tree
Showing 22 changed files with 938 additions and 44 deletions.
5 changes: 5 additions & 0 deletions App/FeatureSet/BaseAPI/Index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down
1 change: 1 addition & 0 deletions Common/Types/Icon/IconProp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum IconProp {
TableCells = "TableCells",
Layout = "Layout",
Compass = "Compass",
ShieldCheck = "ShieldCheck",
User = "User",
Disc = "Disc",
Settings = "Settings",
Expand Down
77 changes: 77 additions & 0 deletions CommonServer/API/UserTwoFactorAuthAPI.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class MigrationName1722031205897 implements MigrationInterface {
public name = "MigrationName1722031205897";

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,4 +75,5 @@ export default [
MigrationName1721159743714,
MigrationName1721754545771,
MigrationName1721779190475,
MigrationName1722031205897,
];
2 changes: 2 additions & 0 deletions CommonServer/Services/Index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -235,6 +236,7 @@ const services: Array<BaseService> = [
UserOnCallLogService,
UserOnCallLogTimelineService,
UserSmsService,
UserTwoFactorAuthService,

WorkflowLogService,
WorkflowService,
Expand Down
58 changes: 58 additions & 0 deletions CommonServer/Services/UserTwoFactorAuthService.ts
Original file line number Diff line number Diff line change
@@ -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<Model> {
public constructor(postgresDatabase?: PostgresDatabase) {
super(Model, postgresDatabase);
}

protected override async onBeforeCreate(createBy: CreateBy<Model>): Promise<OnCreate<Model>> {

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();
62 changes: 62 additions & 0 deletions CommonServer/Utils/TwoFactorAuth.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}
}
37 changes: 31 additions & 6 deletions CommonServer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit bf031f6

Please sign in to comment.