From fa1eb6a5f0a8dd32f0590db922d8171fa93059bd Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 23 Nov 2024 12:11:19 +0100 Subject: [PATCH] reset totp --- src/command/resetCommand.ts | 10 ++ src/command/resetCommandHandler.ts | 54 +++++++++ src/command/userCommand.ts | 5 + src/command/userCommandHandler.ts | 21 ++++ src/controller/authController.ts | 66 ++--------- src/controller/inviteController.ts | 25 +--- src/controller/resetController.ts | 129 +++++++++++++++++++++ src/data-source.ts | 4 + src/entity/reset.ts | 16 +++ src/helpers/jwtHelper.ts | 36 +++++- src/migrations/1732358596823-resetToken.ts | 24 ++++ src/routes/index.ts | 2 + src/routes/reset.ts | 19 +++ src/service/resetService.ts | 26 +++++ 14 files changed, 354 insertions(+), 83 deletions(-) create mode 100644 src/command/resetCommand.ts create mode 100644 src/command/resetCommandHandler.ts create mode 100644 src/controller/resetController.ts create mode 100644 src/entity/reset.ts create mode 100644 src/migrations/1732358596823-resetToken.ts create mode 100644 src/routes/reset.ts create mode 100644 src/service/resetService.ts diff --git a/src/command/resetCommand.ts b/src/command/resetCommand.ts new file mode 100644 index 0000000..0382771 --- /dev/null +++ b/src/command/resetCommand.ts @@ -0,0 +1,10 @@ +export interface CreateResetCommand { + mail: string; + username: string; + secret: string; +} + +export interface DeleteResetCommand { + token: string; + mail: string; +} diff --git a/src/command/resetCommandHandler.ts b/src/command/resetCommandHandler.ts new file mode 100644 index 0000000..6ef1d00 --- /dev/null +++ b/src/command/resetCommandHandler.ts @@ -0,0 +1,54 @@ +import { dataSource } from "../data-source"; +import { reset } from "../entity/reset"; +import InternalException from "../exceptions/internalException"; +import { StringHelper } from "../helpers/stringHelper"; +import { CreateResetCommand, DeleteResetCommand } from "./resetCommand"; + +export default abstract class ResetCommandHandler { + /** + * @description create user + * @param CreateResetCommand + * @returns {Promise} + */ + static async create(createReset: CreateResetCommand): Promise { + const token = StringHelper.random(32); + + return await dataSource + .createQueryBuilder() + .insert() + .into(reset) + .values({ + token: token, + mail: createReset.mail, + username: createReset.username, + secret: createReset.secret, + }) + .orUpdate(["token", "secret"], ["mail"]) + .execute() + .then((result) => { + return token; + }) + .catch((err) => { + throw new InternalException("Failed saving reset", err); + }); + } + + /** + * @description delete reset by mail and token + * @param DeleteRefreshCommand + * @returns {Promise} + */ + static async deleteByTokenAndMail(deleteReset: DeleteResetCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(reset) + .where("reset.token = :token", { token: deleteReset.token }) + .andWhere("reset.mail = :mail", { mail: deleteReset.mail }) + .execute() + .then((res) => {}) + .catch((err) => { + throw new InternalException("failed reset removal", err); + }); + } +} diff --git a/src/command/userCommand.ts b/src/command/userCommand.ts index b61b578..cf23bb8 100644 --- a/src/command/userCommand.ts +++ b/src/command/userCommand.ts @@ -15,6 +15,11 @@ export interface UpdateUserCommand { lastname: string; } +export interface UpdateUserSecretCommand { + id: number; + secret: string; +} + export interface TransferUserOwnerCommand { fromId: number; toId: number; diff --git a/src/command/userCommandHandler.ts b/src/command/userCommandHandler.ts index a88979b..6d1c616 100644 --- a/src/command/userCommandHandler.ts +++ b/src/command/userCommandHandler.ts @@ -8,6 +8,7 @@ import { TransferUserOwnerCommand, UpdateUserCommand, UpdateUserRolesCommand, + UpdateUserSecretCommand, } from "./userCommand"; import UserService from "../service/userService"; @@ -62,6 +63,26 @@ export default abstract class UserCommandHandler { }); } + /** + * @description update user + * @param UpdateUserSecretCommand + * @returns {Promise} + */ + static async updateSecret(updateUser: UpdateUserSecretCommand): Promise { + return await dataSource + .createQueryBuilder() + .update(user) + .set({ + secret: updateUser.secret, + }) + .where("id = :id", { id: updateUser.id }) + .execute() + .then(() => {}) + .catch((err) => { + throw new InternalException("Failed updating user secret", err); + }); + } + /** * @description update user roles * @param UpdateUserRolesCommand diff --git a/src/controller/authController.ts b/src/controller/authController.ts index f68aab2..7b2494b 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -22,7 +22,7 @@ export async function login(req: Request, res: Response): Promise { let username = req.body.username; let totp = req.body.totp; - let { id, secret, mail, firstname, lastname, isOwner } = await UserService.getByUsername(username); + let { id, secret } = await UserService.getByUsername(username); let valid = speakeasy.totp.verify({ secret: secret, @@ -35,39 +35,12 @@ export async function login(req: Request, res: Response): Promise { throw new UnauthorizedRequestException("Token not valid or expired"); } - let userPermissions = await UserPermissionService.getByUser(id); - let userPermissionStrings = userPermissions.map((e) => e.permission); - let userRoles = await UserService.getAssignedRolesByUserId(id); - let rolePermissions = userRoles.length != 0 ? await RolePermissionService.getByRoles(userRoles.map((e) => e.id)) : []; - let rolePermissionStrings = rolePermissions.map((e) => e.permission); - let permissionObject = PermissionHelper.convertToObject([...userPermissionStrings, ...rolePermissionStrings]); - - let jwtData: JWTToken = { - userId: id, - mail: mail, - username: username, - firstname: firstname, - lastname: lastname, - isOwner: isOwner, - permissions: permissionObject, - }; - - let accessToken: string; - let refreshToken: string; - - JWTHelper.create(jwtData) - .then((result) => { - accessToken = result; - }) - .catch((err) => { - console.log(err); - throw new InternalException("Failed accessToken creation", err); - }); + let accessToken = await JWTHelper.buildToken(id); let refreshCommand: CreateRefreshCommand = { userId: id, }; - refreshToken = await RefreshCommandHandler.create(refreshCommand); + let refreshToken = await RefreshCommandHandler.create(refreshCommand); res.json({ accessToken, @@ -106,40 +79,15 @@ export async function refresh(req: Request, res: Response): Promise { throw new UnauthorizedRequestException("user not identified with token and refresh"); } - let { id, username, mail, firstname, lastname, isOwner } = await UserService.getById(tokenUserId); - - let permissions = await UserPermissionService.getByUser(id); - let permissionStrings = permissions.map((e) => e.permission); - let permissionObject = PermissionHelper.convertToObject(permissionStrings); - - let jwtData: JWTToken = { - userId: id, - mail: mail, - username: username, - firstname: firstname, - lastname: lastname, - isOwner: isOwner, - permissions: permissionObject, - }; - - let accessToken: string; - let refreshToken: string; - - JWTHelper.create(jwtData) - .then((result) => { - accessToken = result; - }) - .catch((err) => { - throw new InternalException("Failed accessToken creation", err); - }); + let accessToken = await JWTHelper.buildToken(tokenUserId); let refreshCommand: CreateRefreshCommand = { - userId: id, + userId: tokenUserId, }; - refreshToken = await RefreshCommandHandler.create(refreshCommand); + let refreshToken = await RefreshCommandHandler.create(refreshCommand); let removeToken: DeleteRefreshCommand = { - userId: id, + userId: tokenUserId, token: refresh, }; await RefreshCommandHandler.deleteByToken(removeToken); diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index 0b2b207..a6ff7eb 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -128,33 +128,12 @@ export async function finishInvite(req: Request, res: Response, grantAdmin: bool }; let id = await UserCommandHandler.create(createUser); - let jwtData: JWTToken = { - userId: id, - mail: mail, - username: username, - firstname: firstname, - lastname: lastname, - isOwner: grantAdmin, - permissions: { - ...(grantAdmin ? { admin: true } : {}), - }, - }; - - let accessToken: string; - let refreshToken: string; - - JWTHelper.create(jwtData) - .then((result) => { - accessToken = result; - }) - .catch((err) => { - throw new InternalException("Failed accessToken creation", err); - }); + let accessToken = await JWTHelper.buildToken(id); let refreshCommand: CreateRefreshCommand = { userId: id, }; - refreshToken = await RefreshCommandHandler.create(refreshCommand); + let refreshToken = await RefreshCommandHandler.create(refreshCommand); let deleteInvite: DeleteInviteCommand = { mail: mail, diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts new file mode 100644 index 0000000..32f2004 --- /dev/null +++ b/src/controller/resetController.ts @@ -0,0 +1,129 @@ +import { Request, Response } from "express"; +import { JWTHelper } from "../helpers/jwtHelper"; +import { JWTToken } from "../type/jwtTypes"; +import InternalException from "../exceptions/internalException"; +import RefreshCommandHandler from "../command/refreshCommandHandler"; +import { CreateRefreshCommand } from "../command/refreshCommand"; +import speakeasy from "speakeasy"; +import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; +import QRCode from "qrcode"; +import { CreateResetCommand, DeleteResetCommand } from "../command/resetCommand"; +import ResetCommandHandler from "../command/resetCommandHandler"; +import MailHelper from "../helpers/mailHelper"; +import ResetService from "../service/resetService"; +import UserService from "../service/userService"; +import { CLUB_NAME } from "../env.defaults"; +import PermissionHelper from "../helpers/permissionHelper"; +import RolePermissionService from "../service/rolePermissionService"; +import UserPermissionService from "../service/userPermissionService"; +import { UpdateUserSecretCommand } from "../command/userCommand"; +import UserCommandHandler from "../command/userCommandHandler"; + +/** + * @description request totp reset + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function startReset(req: Request, res: Response): Promise { + let origin = req.headers.origin; + let username = req.body.username; + + let { mail } = await UserService.getByUsername(username); + + var secret = speakeasy.generateSecret({ length: 20, name: `Mitgliederverwaltung ${CLUB_NAME}` }); + + let createReset: CreateResetCommand = { + username: username, + mail: mail, + secret: secret.base32, + }; + let token = await ResetCommandHandler.create(createReset); + + // sendmail + let mailhelper = new MailHelper(); + await mailhelper.sendMail( + mail, + `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Öffne folgenden Link: ${origin}/reset/reset?mail=${mail}&token=${token}` + ); + + res.sendStatus(204); +} + +/** + * @description verify reset link + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function verifyReset(req: Request, res: Response): Promise { + let mail = req.body.mail; + let token = req.body.token; + + let { secret } = await ResetService.getByMailAndToken(mail, token); + + const url = `otpauth://totp/Mitgliederverwaltung ${CLUB_NAME}?secret=${secret}`; + + QRCode.toDataURL(url) + .then((result) => { + res.json({ + dataUrl: result, + otp: secret, + }); + }) + .catch((err) => { + throw new InternalException("QRCode not created", err); + }); +} + +/** + * @description finishReset + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function finishReset(req: Request, res: Response): Promise { + let mail = req.body.mail; + let token = req.body.token; + let totp = req.body.totp; + + let { secret, username } = await ResetService.getByMailAndToken(mail, token); + + let valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: totp, + window: 2, + }); + + if (!valid) { + throw new UnauthorizedRequestException("Token not valid or expired"); + } + + let { id } = await UserService.getByUsername(username); + + let updateUserSecret: UpdateUserSecretCommand = { + id, + secret, + }; + await UserCommandHandler.updateSecret(updateUserSecret); + + let accessToken = await JWTHelper.buildToken(id); + + let refreshCommand: CreateRefreshCommand = { + userId: id, + }; + let refreshToken = await RefreshCommandHandler.create(refreshCommand); + + let deleteReset: DeleteResetCommand = { + mail: mail, + token: token, + }; + await ResetCommandHandler.deleteByTokenAndMail(deleteReset); + + res.json({ + accessToken, + refreshToken, + }); +} diff --git a/src/data-source.ts b/src/data-source.ts index 3801455..39a56c7 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -40,6 +40,8 @@ import { Protocol1729347911107 } from "./migrations/1729347911107-protocol"; import { calendar } from "./entity/calendar"; import { calendarType } from "./entity/calendarType"; import { Calendar1729947763295 } from "./migrations/1729947763295-calendar"; +import { reset } from "./entity/reset"; +import { ResetToken1732358596823 } from "./migrations/1732358596823-resetToken"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -55,6 +57,7 @@ const dataSource = new DataSource({ user, refresh, invite, + reset, userPermission, role, rolePermission, @@ -90,6 +93,7 @@ const dataSource = new DataSource({ Ownership1728313041449, Protocol1729347911107, Calendar1729947763295, + ResetToken1732358596823, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/reset.ts b/src/entity/reset.ts new file mode 100644 index 0000000..a7e2b92 --- /dev/null +++ b/src/entity/reset.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity() +export class reset { + @PrimaryColumn({ type: "varchar", length: 255 }) + mail: string; + + @Column({ type: "varchar", length: 255 }) + token: string; + + @Column({ type: "varchar", length: 255 }) + username: string; + + @Column({ type: "varchar", length: 255 }) + secret: string; +} diff --git a/src/helpers/jwtHelper.ts b/src/helpers/jwtHelper.ts index 5d79921..993f41f 100644 --- a/src/helpers/jwtHelper.ts +++ b/src/helpers/jwtHelper.ts @@ -1,6 +1,11 @@ import jwt from "jsonwebtoken"; -import { JWTData } from "../type/jwtTypes"; +import { JWTData, JWTToken } from "../type/jwtTypes"; import { JWT_SECRET, JWT_EXPIRATION } from "../env.defaults"; +import InternalException from "../exceptions/internalException"; +import RolePermissionService from "../service/rolePermissionService"; +import UserPermissionService from "../service/userPermissionService"; +import UserService from "../service/userService"; +import PermissionHelper from "./permissionHelper"; export abstract class JWTHelper { static validate(token: string): Promise { @@ -38,4 +43,33 @@ export abstract class JWTHelper { } }); } + + static async buildToken(id: number): Promise { + let { firstname, lastname, mail, username, isOwner } = await UserService.getById(id); + let userPermissions = await UserPermissionService.getByUser(id); + let userPermissionStrings = userPermissions.map((e) => e.permission); + let userRoles = await UserService.getAssignedRolesByUserId(id); + let rolePermissions = + userRoles.length != 0 ? await RolePermissionService.getByRoles(userRoles.map((e) => e.id)) : []; + let rolePermissionStrings = rolePermissions.map((e) => e.permission); + let permissionObject = PermissionHelper.convertToObject([...userPermissionStrings, ...rolePermissionStrings]); + + let jwtData: JWTToken = { + userId: id, + mail: mail, + username: username, + firstname: firstname, + lastname: lastname, + isOwner: isOwner, + permissions: permissionObject, + }; + + return await JWTHelper.create(jwtData) + .then((result) => { + return result; + }) + .catch((err) => { + throw new InternalException("Failed accessToken creation", err); + }); + } } diff --git a/src/migrations/1732358596823-resetToken.ts b/src/migrations/1732358596823-resetToken.ts new file mode 100644 index 0000000..ba4b4d8 --- /dev/null +++ b/src/migrations/1732358596823-resetToken.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class ResetToken1732358596823 implements MigrationInterface { + name = "ResetToken1732358596823"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "reset", + columns: [ + { name: "mail", type: "varchar", length: "255", isPrimary: true, isNullable: false }, + { name: "token", type: "varchar", length: "255", isNullable: false }, + { name: "username", type: "varchar", length: "255", isNullable: false }, + { name: "secret", type: "varchar", length: "255", isNullable: false }, + ], + }), + true + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("reset"); + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts index b9281d0..5025c17 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ import errorHandler from "../middleware/errorHandler"; import publicAvailable from "./public"; import setup from "./setup"; +import reset from "./reset"; import auth from "./auth"; import admin from "./admin/index"; import user from "./user"; @@ -25,6 +26,7 @@ export default (app: Express) => { app.use("/public", publicAvailable); app.use("/setup", allowSetup, setup); + app.use("/reset", reset); app.use("/auth", auth); app.use(authenticate); app.use("/admin", admin); diff --git a/src/routes/reset.ts b/src/routes/reset.ts new file mode 100644 index 0000000..acb1516 --- /dev/null +++ b/src/routes/reset.ts @@ -0,0 +1,19 @@ +import express from "express"; +import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; +import { finishReset, startReset, verifyReset } from "../controller/resetController"; + +var router = express.Router({ mergeParams: true }); + +router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => { + await verifyReset(req, res); +}); + +router.post("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username"]), async (req, res) => { + await startReset(req, res); +}); + +router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { + await finishReset(req, res); +}); + +export default router; diff --git a/src/service/resetService.ts b/src/service/resetService.ts new file mode 100644 index 0000000..802548e --- /dev/null +++ b/src/service/resetService.ts @@ -0,0 +1,26 @@ +import { dataSource } from "../data-source"; +import { reset } from "../entity/reset"; +import InternalException from "../exceptions/internalException"; + +export default abstract class ResetService { + /** + * @description get reset by id + * @param mail string + * @param token string + * @returns {Promise} + */ + static async getByMailAndToken(mail: string, token: string): Promise { + return await dataSource + .getRepository(reset) + .createQueryBuilder("reset") + .where("reset.mail = :mail", { mail: mail }) + .andWhere("reset.token = :token", { token: token }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("reset not found by mail and token", err); + }); + } +}