forked from OneUptime/oneuptime
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add support for two-factor authentication in user profile
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
Showing
22 changed files
with
938 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}, | ||
); | ||
} | ||
} |
33 changes: 33 additions & 0 deletions
33
CommonServer/Infrastructure/Postgres/SchemaMigrations/1722031205897-MigrationName.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}); | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.