From 03a5bb3592969bf2be886e26677ae5d383589665 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 3 May 2025 09:09:52 +0200 Subject: [PATCH 1/6] change user model to login routine --- src/data-source.ts | 2 ++ src/entity/management/user.ts | 19 +++++++++-- src/enums/loginRoutineEnum.ts | 4 +++ src/helpers/backupHelper.ts | 2 ++ .../1746252454922-UserLoginRoutine.ts | 32 +++++++++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/enums/loginRoutineEnum.ts create mode 100644 src/migrations/1746252454922-UserLoginRoutine.ts diff --git a/src/data-source.ts b/src/data-source.ts index 6d40b6c..dad9827 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -55,6 +55,7 @@ import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpd import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberCreatedAt"; import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; +import { SettingsFromEnv_SET1745059495808 } from "./migrations/1745059495808-settingsFromEnv_set"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -119,6 +120,7 @@ const dataSource = new DataSource({ SettingsFromEnv1745059495808, SettingsFromEnv_SET1745059495808, MemberCreatedAt1746006549262, + UserLoginRoutine1746252454922, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/management/user.ts b/src/entity/management/user.ts index 94ab3a3..a833864 100644 --- a/src/entity/management/user.ts +++ b/src/entity/management/user.ts @@ -1,6 +1,7 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from "typeorm"; import { role } from "./role"; import { userPermission } from "./user_permission"; +import { LoginRoutineEnum } from "../../enums/loginRoutineEnum"; @Entity() export class user { @@ -19,11 +20,23 @@ export class user { @Column({ type: "varchar", length: 255 }) lastname: string; - @Column({ type: "varchar", length: 255 }) + @Column({ type: "text", select: false }) secret: string; - @Column({ type: "boolean", default: false }) - static: boolean; + @Column({ + type: "varchar", + length: "255", + default: LoginRoutineEnum.totp, + transformer: { + to(value: LoginRoutineEnum) { + return value.toString(); + }, + from(value: string) { + return LoginRoutineEnum[value as keyof typeof LoginRoutineEnum]; + }, + }, + }) + routine: LoginRoutineEnum; @Column({ type: "boolean", default: false }) isOwner: boolean; diff --git a/src/enums/loginRoutineEnum.ts b/src/enums/loginRoutineEnum.ts new file mode 100644 index 0000000..4d42334 --- /dev/null +++ b/src/enums/loginRoutineEnum.ts @@ -0,0 +1,4 @@ +export enum LoginRoutineEnum { + password = "password", // login with self defined password + totp = "totp", // login with totp by auth apps +} diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 4534a30..f107881 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -7,6 +7,7 @@ import UserService from "../service/management/userService"; import DatabaseActionException from "../exceptions/databaseActionException"; import { availableTemplates } from "../type/templateTypes"; import SettingHelper from "./settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; export type BackupSection = | "member" @@ -806,6 +807,7 @@ export default abstract class BackupHelper { let roles = await this.transactionManager.getRepository("role").find(); let dataWithMappedIds = (data?.["user"] ?? []).map((u) => ({ ...u, + routine: u.routine ?? LoginRoutineEnum.totp, roles: u.roles.map((r: any) => ({ ...r, id: roles.find((role) => role.role == r.role)?.id ?? undefined, diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts new file mode 100644 index 0000000..0da51e0 --- /dev/null +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { getDefaultByORM, getTypeByORM } from "./ormHelper"; + +export class UserLoginRoutine1746252454922 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumns("user", ["secret", "static"]); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("text") }), + new TableColumn({ name: "routine", ...getTypeByORM("varchar") }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + } + + public async down(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumn("user", "secret"); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("varchar") }), + new TableColumn({ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + + await queryRunner.dropColumn("user", "routine"); + } +} From a476bf6823b49e30628f9413ba781fb0eb4df7f6 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sun, 4 May 2025 19:01:06 +0200 Subject: [PATCH 2/6] migration change on default value and encrypted storage --- src/controller/authController.ts | 1 + src/controller/userController.ts | 6 ++- src/controller/webapiController.ts | 8 --- src/data-source.ts | 4 +- src/entity/management/user.ts | 8 ++- src/env.defaults.ts | 4 +- src/exceptions/databaseActionException.ts | 2 +- src/helpers/backupHelper.ts | 1 + src/helpers/codingHelper.ts | 51 +++++++++++-------- .../1746252454922-UserLoginRoutine.ts | 9 +++- src/service/management/userService.ts | 24 +++++++++ 11 files changed, 82 insertions(+), 36 deletions(-) diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 9ecfa64..80492ea 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -19,6 +19,7 @@ export async function login(req: Request, res: Response): Promise { let username = req.body.username; let totp = req.body.totp; + // TODO: change to first routine and later login password/totp let { id, secret } = await UserService.getByUsername(username); let valid = speakeasy.totp.verify({ diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 8827dc9..c8001a7 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -31,7 +31,9 @@ export async function getMeById(req: Request, res: Response): Promise { export async function getMyTotp(req: Request, res: Response): Promise { const userId = req.userId; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + console.log(secret); const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; @@ -57,7 +59,7 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { const userId = req.userId; let totp = req.body.totp; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); let valid = speakeasy.totp.verify({ secret: secret, encoding: "base32", diff --git a/src/controller/webapiController.ts b/src/controller/webapiController.ts index e8e4206..c1153fe 100644 --- a/src/controller/webapiController.ts +++ b/src/controller/webapiController.ts @@ -1,13 +1,5 @@ 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, DeleteRefreshCommand } from "../command/refreshCommand"; -import UserService from "../service/management/userService"; -import speakeasy from "speakeasy"; -import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; -import RefreshService from "../service/refreshService"; import WebapiService from "../service/management/webapiService"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; import WebapiCommandHandler from "../command/management/webapi/webapiCommandHandler"; diff --git a/src/data-source.ts b/src/data-source.ts index dad9827..fb60d49 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; -import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; +import { configCheck, DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; import { user } from "./entity/management/user"; import { refresh } from "./entity/refresh"; @@ -57,6 +57,8 @@ import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberC import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; import { SettingsFromEnv_SET1745059495808 } from "./migrations/1745059495808-settingsFromEnv_set"; +configCheck(); + const dataSource = new DataSource({ type: DB_TYPE as any, host: DB_HOST, diff --git a/src/entity/management/user.ts b/src/entity/management/user.ts index a833864..d659acb 100644 --- a/src/entity/management/user.ts +++ b/src/entity/management/user.ts @@ -2,6 +2,8 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from import { role } from "./role"; import { userPermission } from "./user_permission"; import { LoginRoutineEnum } from "../../enums/loginRoutineEnum"; +import { CodingHelper } from "../../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../../env.defaults"; @Entity() export class user { @@ -20,7 +22,11 @@ export class user { @Column({ type: "varchar", length: 255 }) lastname: string; - @Column({ type: "text", select: false }) + @Column({ + type: "text", + select: false, + transformer: CodingHelper.entityBaseCoding(APPLICATION_SECRET, ""), + }) secret: string; @Column({ diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 7bdcb4b..c6df8ae 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -11,7 +11,7 @@ export const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000); -export const APPLICATION_SECRET = process.env.APPLICATION_SECRET; +export const APPLICATION_SECRET = process.env.APPLICATION_SECRET ?? ""; export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true"; export const SECURITY_STRICT_LIMIT_WINDOW = (process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m") as ms.StringValue; @@ -45,6 +45,8 @@ export function configCheck() { if (DB_USERNAME == "" || typeof DB_USERNAME != "string") throw new Error("set valid value to DB_USERNAME"); if (DB_PASSWORD == "" || typeof DB_PASSWORD != "string") throw new Error("set valid value to DB_PASSWORD"); + if (APPLICATION_SECRET == "") throw new Error("set valid APPLICATION_SECRET"); + if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); if (USE_SECURITY_STRICT_LIMIT != "true" && USE_SECURITY_STRICT_LIMIT != "false") diff --git a/src/exceptions/databaseActionException.ts b/src/exceptions/databaseActionException.ts index b0b145c..eba9bb3 100644 --- a/src/exceptions/databaseActionException.ts +++ b/src/exceptions/databaseActionException.ts @@ -2,7 +2,7 @@ import CustomRequestException from "./customRequestException"; export default class DatabaseActionException extends CustomRequestException { constructor(action: string, table: string, err: any) { - let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? "XX"}`; + let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? err?.message ?? "XX"}`; super(500, errstring, err); } } diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index f107881..2f737ad 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -441,6 +441,7 @@ export default abstract class BackupHelper { "user.firstname", "user.lastname", "user.secret", + "user.routine", "user.isOwner", ]) .addSelect(["permissions.permission"]) diff --git a/src/helpers/codingHelper.ts b/src/helpers/codingHelper.ts index e6a79f9..354abc8 100644 --- a/src/helpers/codingHelper.ts +++ b/src/helpers/codingHelper.ts @@ -9,12 +9,13 @@ export abstract class CodingHelper { static entityBaseCoding(key: string = "", fallback: string = ""): ValueTransformer { return { from(val: string | null | undefined): string { - if (!val) return fallback; + if (!val || val == "") return fallback; try { - return CodingHelper.decrypt(key, val) || fallback; + return CodingHelper.decrypt(key, val, true); } catch (error) { console.error("Decryption error:", error); - return fallback; + if (fallback == "") return val; + else return fallback; } }, to(val: string | null | undefined): string { @@ -22,40 +23,47 @@ export abstract class CodingHelper { if (valueToEncrypt === "") return ""; try { - return CodingHelper.encrypt(key, valueToEncrypt); + return CodingHelper.encrypt(key, valueToEncrypt, true); } catch (error) { console.error("Encryption error:", error); + if (fallback == "") return val; return ""; } }, }; } - public static encrypt(phrase: string, content: string): string { + public static encrypt(phrase: string, content: string, passError = false): string { if (!content) return ""; - // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) - const iv = randomBytes(this.ivLength); - const key = scryptSync(phrase, "salt", 32); + try { + // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) + const iv = randomBytes(this.ivLength); + const key = scryptSync(phrase, "salt", 32); - const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); - // Verschlüssele den Inhalt - let encrypted = cipher.update(content, "utf8", "hex"); - encrypted += cipher.final("hex"); + // Verschlüssele den Inhalt + let encrypted = cipher.update(content, "utf8", "hex"); + encrypted += cipher.final("hex"); - // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) - const authTag = cipher.getAuthTag(); + // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) + const authTag = cipher.getAuthTag(); - // Gib das Format: iv:verschlüsselter_text:authTag zurück - return Buffer.concat([ - Uint8Array.from(iv), - Uint8Array.from(Buffer.from(encrypted, "hex")), - Uint8Array.from(authTag), - ]).toString("base64"); + // Gib das Format: iv:verschlüsselter_text:authTag zurück + return Buffer.concat([ + Uint8Array.from(iv), + Uint8Array.from(Buffer.from(encrypted, "hex")), + Uint8Array.from(authTag), + ]).toString("base64"); + } catch (error) { + if (passError) throw error; + console.error("Encryption failed:", error); + return ""; + } } - public static decrypt(phrase: string, content: string): string { + public static decrypt(phrase: string, content: string, passError = false): string { if (!content) return ""; try { @@ -79,6 +87,7 @@ export abstract class CodingHelper { return decrypted; } catch (error) { + if (passError) throw error; console.error("Decryption failed:", error); return ""; } diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts index 0da51e0..3072e37 100644 --- a/src/migrations/1746252454922-UserLoginRoutine.ts +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -1,5 +1,8 @@ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; import { getDefaultByORM, getTypeByORM } from "./ormHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; +import { CodingHelper } from "../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../env.defaults"; export class UserLoginRoutine1746252454922 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { @@ -9,7 +12,11 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.addColumns("user", [ new TableColumn({ name: "secret", ...getTypeByORM("text") }), - new TableColumn({ name: "routine", ...getTypeByORM("varchar") }), + new TableColumn({ + name: "routine", + ...getTypeByORM("varchar"), + default: getDefaultByORM("string", LoginRoutineEnum.totp), + }), ]); await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); diff --git a/src/service/management/userService.ts b/src/service/management/userService.ts index 8fe4dd4..8119d5d 100644 --- a/src/service/management/userService.ts +++ b/src/service/management/userService.ts @@ -129,4 +129,28 @@ export default abstract class UserService { throw new DatabaseActionException("SELECT", "userRoles", err); }); } + + /** + * @description get secret and routine by iser + * @param userId string + * @returns {Promise} + */ + static async getUserSecretAndRoutine(userId: string): Promise { + //TODO: not working yet + return await dataSource + .getRepository(user) + .createQueryBuilder("user") + .select("user.id") + .addSelect("user.secret") + .addSelect("user.routine") + .where("user.id = :id", { id: userId }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + console.log(err); + throw new DatabaseActionException("SELECT", "user credentials", err); + }); + } } From be22c783725403d3e2c6668b9f9bdcea22dc7bb4 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 5 May 2025 14:21:13 +0200 Subject: [PATCH 3/6] login by password or totp --- src/controller/authController.ts | 44 +++++++++++++++++++++------ src/routes/auth.ts | 6 +++- src/service/management/userService.ts | 1 - 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 80492ea..ce674ab 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -8,6 +8,25 @@ import UserService from "../service/management/userService"; import speakeasy from "speakeasy"; import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; import RefreshService from "../service/refreshService"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; + +/** + * @description Check authentication status by token + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function kickof(req: Request, res: Response): Promise { + let username = req.body.username; + + let { routine } = await UserService.getByUsername(username).catch(() => { + throw new UnauthorizedRequestException("Username not found"); + }); + + res.json({ + routine, + }); +} /** * @description Check authentication status by token @@ -17,20 +36,25 @@ import RefreshService from "../service/refreshService"; */ export async function login(req: Request, res: Response): Promise { let username = req.body.username; - let totp = req.body.totp; + let passedSecret = req.body.secret; - // TODO: change to first routine and later login password/totp - let { id, secret } = await UserService.getByUsername(username); + let { id } = await UserService.getByUsername(username); + let { secret, routine } = await UserService.getUserSecretAndRoutine(id); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret == secret; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let accessToken = await JWTHelper.buildToken(id); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b1200bc..c15f7cb 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,8 +1,12 @@ import express from "express"; -import { login, logout, refresh } from "../controller/authController"; +import { kickof, login, logout, refresh } from "../controller/authController"; var router = express.Router({ mergeParams: true }); +router.post("/kickof", async (req, res) => { + await kickof(req, res); +}); + router.post("/login", async (req, res) => { await login(req, res); }); diff --git a/src/service/management/userService.ts b/src/service/management/userService.ts index 8119d5d..d273315 100644 --- a/src/service/management/userService.ts +++ b/src/service/management/userService.ts @@ -136,7 +136,6 @@ export default abstract class UserService { * @returns {Promise} */ static async getUserSecretAndRoutine(userId: string): Promise { - //TODO: not working yet return await dataSource .getRepository(user) .createQueryBuilder("user") From ddb460f8d0a61067741c9821b30be7085c33f5e3 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 5 May 2025 17:43:57 +0200 Subject: [PATCH 4/6] enable switch to pw totp in account settings --- src/command/management/user/userCommand.ts | 3 + .../management/user/userCommandHandler.ts | 1 + src/controller/authController.ts | 1 + src/controller/resetController.ts | 2 + src/controller/userController.ts | 129 +++++++++++++++++- src/routes/user.ts | 33 ++++- 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/command/management/user/userCommand.ts b/src/command/management/user/userCommand.ts index 90f9872..66bab37 100644 --- a/src/command/management/user/userCommand.ts +++ b/src/command/management/user/userCommand.ts @@ -1,3 +1,5 @@ +import { LoginRoutineEnum } from "../../../enums/loginRoutineEnum"; + export interface CreateUserCommand { mail: string; username: string; @@ -18,6 +20,7 @@ export interface UpdateUserCommand { export interface UpdateUserSecretCommand { id: string; secret: string; + routine: LoginRoutineEnum; } export interface TransferUserOwnerCommand { diff --git a/src/command/management/user/userCommandHandler.ts b/src/command/management/user/userCommandHandler.ts index 590b2de..0f89ef5 100644 --- a/src/command/management/user/userCommandHandler.ts +++ b/src/command/management/user/userCommandHandler.ts @@ -75,6 +75,7 @@ export default abstract class UserCommandHandler { .update(user) .set({ secret: updateUser.secret, + routine: updateUser.routine, }) .where("id = :id", { id: updateUser.id }) .execute() diff --git a/src/controller/authController.ts b/src/controller/authController.ts index ce674ab..77a6717 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -50,6 +50,7 @@ export async function login(req: Request, res: Response): Promise { window: 2, }); } else { + console.log(passedSecret, secret, passedSecret == secret); valid = passedSecret == secret; } diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index 3592639..1a12b3f 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -14,6 +14,7 @@ import UserService from "../service/management/userService"; import { UpdateUserSecretCommand } from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description request totp reset @@ -101,6 +102,7 @@ export async function finishReset(req: Request, res: Response): Promise { let updateUserSecret: UpdateUserSecretCommand = { id, secret, + routine: LoginRoutineEnum.totp, }; await UserCommandHandler.updateSecret(updateUserSecret); diff --git a/src/controller/userController.ts b/src/controller/userController.ts index c8001a7..1fe92c5 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -4,10 +4,15 @@ import QRCode from "qrcode"; import InternalException from "../exceptions/internalException"; import UserService from "../service/management/userService"; import UserFactory from "../factory/admin/management/user"; -import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/management/user/userCommand"; +import { + TransferUserOwnerCommand, + UpdateUserCommand, + UpdateUserSecretCommand, +} from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get my by id @@ -22,6 +27,21 @@ export async function getMeById(req: Request, res: Response): Promise { res.json(UserFactory.mapToSingle(user)); } +/** + * @description get my routine by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMyRoutine(req: Request, res: Response): Promise { + const id = req.userId; + let user = await UserService.getById(id); + + res.json({ + routine: user.routine, + }); +} + /** * @description get my totp * @param req {Request} Express req object @@ -33,8 +53,6 @@ export async function getMyTotp(req: Request, res: Response): Promise { let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); - console.log(secret); - const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) @@ -60,6 +78,11 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { let totp = req.body.totp; let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine != LoginRoutineEnum.totp) { + throw new ForbiddenRequestException("only allowed for totp login"); + } + let valid = speakeasy.totp.verify({ secret: secret, encoding: "base32", @@ -73,6 +96,106 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { res.sendStatus(204); } +/** + * @description change my password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeMyPassword(req: Request, res: Response): Promise { + const userId = req.userId; + let current = req.body.current; + let newpassword = req.body.newpassword; + + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine == LoginRoutineEnum.password && current != secret) { + throw new ForbiddenRequestException("passwords do not match"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description get change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getChangeToTOTP(req: Request, res: Response): Promise { + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); + + QRCode.toDataURL(secret.otpauth_url) + .then((result) => { + res.json({ + dataUrl: result, + otp: secret.base32, + }); + }) + .catch((err) => { + throw new InternalException("QRCode not created", err); + }); +} + +/** + * @description change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToTOTP(req: Request, res: Response): Promise { + const userId = req.userId; + let otp = req.body.otp; + let totp = req.body.totp; + + let valid = speakeasy.totp.verify({ + secret: otp, + encoding: "base32", + token: totp, + window: 2, + }); + + if (!valid) { + throw new InternalException("Token not valid or expired"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: otp, + routine: LoginRoutineEnum.totp, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description change to password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToPW(req: Request, res: Response): Promise { + const userId = req.userId; + let newpassword = req.body.newpassword; + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + /** * @description transferOwnership * @param req {Request} Express req object diff --git a/src/routes/user.ts b/src/routes/user.ts index d196e16..90ba489 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,5 +1,16 @@ import express from "express"; -import { getMeById, getMyTotp, transferOwnership, updateMe, verifyMyTotp } from "../controller/userController"; +import { + changeMyPassword, + changeToPW, + changeToTOTP, + getChangeToTOTP, + getMeById, + getMyRoutine, + getMyTotp, + transferOwnership, + updateMe, + verifyMyTotp, +} from "../controller/userController"; var router = express.Router({ mergeParams: true }); @@ -7,14 +18,34 @@ router.get("/me", async (req, res) => { await getMeById(req, res); }); +router.get("/routine", async (req, res) => { + await getMyRoutine(req, res); +}); + router.get("/totp", async (req, res) => { await getMyTotp(req, res); }); +router.get("/changeToTOTP", async (req, res) => { + await getChangeToTOTP(req, res); +}); + router.post("/verify", async (req, res) => { await verifyMyTotp(req, res); }); +router.post("/changepw", async (req, res) => { + await changeMyPassword(req, res); +}); + +router.post("/changeToTOTP", async (req, res) => { + await changeToTOTP(req, res); +}); + +router.post("/changeToPW", async (req, res) => { + await changeToPW(req, res); +}); + router.put("/transferOwner", async (req, res) => { await transferOwnership(req, res); }); From 0ea12eaafce45b14d4f3f585acad0c3f55372513 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 08:37:56 +0200 Subject: [PATCH 5/6] enable password on invite or reset --- src/command/management/user/userCommand.ts | 1 + .../management/user/userCommandHandler.ts | 1 + src/controller/authController.ts | 3 ++- src/controller/inviteController.ts | 26 ++++++++++++------- src/controller/resetController.ts | 26 ++++++++++++------- src/helpers/settingsHelper.ts | 16 +++++++----- src/routes/invite.ts | 10 ++++--- src/routes/reset.ts | 10 ++++--- 8 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/command/management/user/userCommand.ts b/src/command/management/user/userCommand.ts index 66bab37..cb19989 100644 --- a/src/command/management/user/userCommand.ts +++ b/src/command/management/user/userCommand.ts @@ -7,6 +7,7 @@ export interface CreateUserCommand { lastname: string; secret: string; isOwner: boolean; + routine: LoginRoutineEnum; } export interface UpdateUserCommand { diff --git a/src/command/management/user/userCommandHandler.ts b/src/command/management/user/userCommandHandler.ts index 0f89ef5..daa5535 100644 --- a/src/command/management/user/userCommandHandler.ts +++ b/src/command/management/user/userCommandHandler.ts @@ -31,6 +31,7 @@ export default abstract class UserCommandHandler { lastname: createUser.lastname, secret: createUser.secret, isOwner: createUser.isOwner, + routine: createUser.routine, }) .execute() .then((result) => { diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 77a6717..6302a3c 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -41,6 +41,8 @@ export async function login(req: Request, res: Response): Promise { let { id } = await UserService.getByUsername(username); let { secret, routine } = await UserService.getUserSecretAndRoutine(id); + console.log(secret, passedSecret); + let valid = false; if (routine == LoginRoutineEnum.totp) { valid = speakeasy.totp.verify({ @@ -50,7 +52,6 @@ export async function login(req: Request, res: Response): Promise { window: 2, }); } else { - console.log(passedSecret, secret, passedSecret == secret); valid = passedSecret == secret; } diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index a183cbc..9492e10 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -16,6 +16,7 @@ import UserService from "../service/management/userService"; import CustomRequestException from "../exceptions/customRequestException"; import InviteFactory from "../factory/admin/management/invite"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get all invites @@ -112,20 +113,26 @@ export async function verifyInvite(req: Request, res: Response): Promise { */ export async function finishInvite(req: Request, res: Response, grantAdmin: boolean = false): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username, firstname, lastname } = await InviteService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let createUser: CreateUserCommand = { @@ -133,8 +140,9 @@ export async function finishInvite(req: Request, res: Response, grantAdmin: bool firstname: firstname, lastname: lastname, mail: mail, - secret: secret, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, isOwner: grantAdmin, + routine, }; let id = await UserCommandHandler.create(createUser); diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index 1a12b3f..7ffafe5 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -81,28 +81,34 @@ export async function verifyReset(req: Request, res: Response): Promise { */ export async function finishReset(req: Request, res: Response): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username } = await ResetService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let { id } = await UserService.getByUsername(username); let updateUserSecret: UpdateUserSecretCommand = { id, - secret, - routine: LoginRoutineEnum.totp, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, + routine, }; await UserCommandHandler.updateSecret(updateUserSecret); diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index b90cd07..526e10a 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -56,12 +56,17 @@ export default abstract class SettingHelper { return rawValue as unknown as SettingValueMapping[K]; } + let processedValue = rawValue; + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); + } + const baseType = typeof settingType.type === "string" ? (settingType.type.split("/")[0] as SettingTypeAtom) : (settingType.type as SettingTypeAtom); - return this.converters[baseType].fromString(rawValue) as unknown as SettingValueMapping[K]; + return this.converters[baseType].fromString(processedValue) as unknown as SettingValueMapping[K]; } /** @@ -81,11 +86,11 @@ export default abstract class SettingHelper { const settingType = settingsType[key]; this.validateSetting(key, stringValue); - const oldValue = this.getSetting(key); - let finalValue = stringValue; + const oldValue = cloneDeep(this.settings[key]); + let newValue = stringValue; if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { - finalValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); + newValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); } this.settings[key] = stringValue; @@ -94,10 +99,9 @@ export default abstract class SettingHelper { await SettingCommandHandler.create({ topic, key: settingKey, - value: finalValue, + value: newValue, }); - const newValue = this.getSetting(key); this.notifyListeners(key, newValue, oldValue); } diff --git a/src/routes/invite.ts b/src/routes/invite.ts index 5d8794e..ebb4ddd 100644 --- a/src/routes/invite.ts +++ b/src/routes/invite.ts @@ -8,8 +8,12 @@ router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mai await verifyInvite(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine "]), + async (req, res) => { + await finishInvite(req, res); + } +); export default router; diff --git a/src/routes/reset.ts b/src/routes/reset.ts index acb1516..31df6c4 100644 --- a/src/routes/reset.ts +++ b/src/routes/reset.ts @@ -12,8 +12,12 @@ router.post("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username" await startReset(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishReset(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine"]), + async (req, res) => { + await finishReset(req, res); + } +); export default router; From cac784474c0f423ec2b5f3c1eef55f2eac9a57bf Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 09:17:55 +0200 Subject: [PATCH 6/6] update migration to work with postgre --- src/migrations/1746252454922-UserLoginRoutine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts index 3072e37..b11f9b0 100644 --- a/src/migrations/1746252454922-UserLoginRoutine.ts +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -11,7 +11,7 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.dropColumns("user", ["secret", "static"]); await queryRunner.addColumns("user", [ - new TableColumn({ name: "secret", ...getTypeByORM("text") }), + new TableColumn({ name: "secret", ...getTypeByORM("text"), default: getDefaultByORM("string") }), new TableColumn({ name: "routine", ...getTypeByORM("varchar"), @@ -28,7 +28,7 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.dropColumn("user", "secret"); await queryRunner.addColumns("user", [ - new TableColumn({ name: "secret", ...getTypeByORM("varchar") }), + new TableColumn({ name: "secret", ...getTypeByORM("varchar"), default: getDefaultByORM("string") }), new TableColumn({ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) }), ]);