diff --git a/.env.example b/.env.example index 508c968f7..ae508d4ad 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_PORT=5000 - +APP_KEY= JWT_SECRET= JWT_EXPIRES_IN_MINUTES=60 diff --git a/src/app/factory/UserFactory.ts b/src/app/factory/UserFactory.ts index 961e8a80b..362745b38 100644 --- a/src/app/factory/UserFactory.ts +++ b/src/app/factory/UserFactory.ts @@ -1,8 +1,8 @@ +import User from "@src/app/models/auth/User"; import { GROUPS, ROLES } from "@src/config/acl"; import Factory from "@src/core/base/Factory"; -import hashPassword from "@src/core/domains/auth/utils/hashPassword"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; import { IModelAttributes } from "@src/core/interfaces/IModel"; -import User from "@src/app/models/auth/User"; class UserFactory extends Factory { @@ -11,7 +11,7 @@ class UserFactory extends Factory { getDefinition(): IModelAttributes | null { return { email: this.faker.internet.email(), - hashedPassword: hashPassword(this.faker.internet.password()), + hashedPassword: cryptoService().hash(this.faker.internet.password()), roles: [ROLES.USER], groups: [GROUPS.User], firstName: this.faker.person.firstName(), diff --git a/src/config/app.ts b/src/config/app.ts index 10a748297..4d92a8cbb 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -2,12 +2,16 @@ import { EnvironmentDevelopment, EnvironmentType } from '@src/core/consts/Enviro // App configuration type definition export type IAppConfig = { - env: EnvironmentType + appKey: string; + env: EnvironmentType; } // App configuration const appConfig: IAppConfig = { + // App key + appKey: process.env.APP_KEY ?? '', + // Environment env: (process.env.APP_ENV as EnvironmentType) ?? EnvironmentDevelopment, diff --git a/src/core/domains/auth/observers/UserObserver.ts b/src/core/domains/auth/observers/UserObserver.ts index 8ba102efc..c0133ab5e 100644 --- a/src/core/domains/auth/observers/UserObserver.ts +++ b/src/core/domains/auth/observers/UserObserver.ts @@ -1,10 +1,10 @@ import { UserCreatedListener } from "@src/app/events/listeners/UserCreatedListener"; import { UserAttributes } from "@src/app/models/auth/User"; -import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import { IBaseEvent } from "@src/core/domains/events/interfaces/IBaseEvent"; import Observer from "@src/core/domains/observer/services/Observer"; import { ICtor } from "@src/core/interfaces/ICtor"; import { App } from "@src/core/services/App"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; /** * Observer for the User model. @@ -81,7 +81,10 @@ export default class UserObserver extends Observer { return data } - data.hashedPassword = hashPassword(data.password); + // Hash the password + data.hashedPassword = cryptoService().hash(data.password); + + // Delete the password from the data delete data.password; return data diff --git a/src/core/domains/auth/services/JwtAuthService.ts b/src/core/domains/auth/services/JwtAuthService.ts index 10f6103d5..bee8e7a5e 100644 --- a/src/core/domains/auth/services/JwtAuthService.ts +++ b/src/core/domains/auth/services/JwtAuthService.ts @@ -16,7 +16,6 @@ import ApiToken from "@src/core/domains/auth/models/ApiToken"; import ApiTokenRepository from "@src/core/domains/auth/repository/ApiTokenRepitory"; import UserRepository from "@src/core/domains/auth/repository/UserRepository"; import ACLService from "@src/core/domains/auth/services/ACLService"; -import comparePassword from "@src/core/domains/auth/utils/comparePassword"; import createJwt from "@src/core/domains/auth/utils/createJwt"; import decodeJwt from "@src/core/domains/auth/utils/decodeJwt"; import generateToken from "@src/core/domains/auth/utils/generateToken"; @@ -26,6 +25,7 @@ import Router from "@src/core/domains/http/router/Router"; import { app } from "@src/core/services/App"; import { JsonWebTokenError } from "jsonwebtoken"; import { DataTypes } from "sequelize"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; /** * Short hand for app('auth.jwt') @@ -108,7 +108,7 @@ class JwtAuthService extends BaseAuthAdapter implements IJwtAuthServ throw new UnauthorizedError() } - if (!comparePassword(password, hashedPassword)) { + if (!cryptoService().verifyHash(password, hashedPassword)) { throw new UnauthorizedError() } diff --git a/src/core/domains/auth/usecase/LoginUseCase.ts b/src/core/domains/auth/usecase/LoginUseCase.ts index ce70cf7d6..3473ad9ec 100644 --- a/src/core/domains/auth/usecase/LoginUseCase.ts +++ b/src/core/domains/auth/usecase/LoginUseCase.ts @@ -1,9 +1,9 @@ -import HttpContext from "@src/core/domains/http/context/HttpContext"; -import ApiResponse from "@src/core/domains/http/response/ApiResponse"; import UnauthorizedError from "@src/core/domains/auth/exceptions/UnauthorizedError"; import { authJwt } from "@src/core/domains/auth/services/JwtAuthService"; -import comparePassword from "@src/core/domains/auth/utils/comparePassword"; +import HttpContext from "@src/core/domains/http/context/HttpContext"; +import ApiResponse from "@src/core/domains/http/response/ApiResponse"; + /** * LoginUseCase handles user authentication by validating credentials and generating JWT tokens @@ -40,12 +40,6 @@ class LoginUseCase { return this.unauthorized('Email or password is incorrect'); } - const hashedPassword = user.getHashedPassword(); - - if(!hashedPassword || !comparePassword(password, hashedPassword)) { - return this.unauthorized('Email or password is incorrect'); - } - let jwtToken!: string; try { diff --git a/src/core/domains/auth/usecase/RegisterUseCase.ts b/src/core/domains/auth/usecase/RegisterUseCase.ts index 86041f71f..245da9391 100644 --- a/src/core/domains/auth/usecase/RegisterUseCase.ts +++ b/src/core/domains/auth/usecase/RegisterUseCase.ts @@ -1,11 +1,11 @@ import { IUserModel } from "@src/core/domains/auth/interfaces/models/IUserModel"; import { acl, auth } from "@src/core/domains/auth/services/AuthService"; import { authJwt } from "@src/core/domains/auth/services/JwtAuthService"; -import hashPassword from "@src/core/domains/auth/utils/hashPassword"; import HttpContext from "@src/core/domains/http/context/HttpContext"; import ApiResponse from "@src/core/domains/http/response/ApiResponse"; import ValidatorResult from "@src/core/domains/validator/data/ValidatorResult"; import { IValidatorResult } from "@src/core/domains/validator/interfaces/IValidatorResult"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; /** * RegisterUseCase handles new user registration @@ -52,12 +52,10 @@ class RegisterUseCase { async createUser(context: HttpContext): Promise { const userAttributes = { email: context.getBody().email, - - hashedPassword: hashPassword(context.getBody().password), + hashedPassword: cryptoService().hash(context.getBody().password), groups: [acl().getDefaultGroup().name], roles: [acl().getGroupRoles(acl().getDefaultGroup()).map(role => role.name)], ...context.getBody() - } // Create and save the user diff --git a/src/core/domains/auth/utils/comparePassword.ts b/src/core/domains/auth/utils/comparePassword.ts index 1ab592d2a..211bddbb1 100644 --- a/src/core/domains/auth/utils/comparePassword.ts +++ b/src/core/domains/auth/utils/comparePassword.ts @@ -6,5 +6,6 @@ import bcrypt from 'bcryptjs' * @param password The plain text password * @param hashedPassword The hashed password * @returns true if the password matches the hashed password, false otherwise + * @deprecated Use cryptoService().verifyHash instead */ export default (password: string, hashedPassword: string): boolean => bcrypt.compareSync(password, hashedPassword) diff --git a/src/core/domains/auth/utils/hashPassword.ts b/src/core/domains/auth/utils/hashPassword.ts index 201c5195f..ecec491e9 100644 --- a/src/core/domains/auth/utils/hashPassword.ts +++ b/src/core/domains/auth/utils/hashPassword.ts @@ -5,6 +5,7 @@ import bcryptjs from 'bcryptjs' * @param password The password to hash * @param salt The salt to use for hashing (optional, default is 10) * @returns The hashed password + * @deprecated Use cryptoService().hash instead */ export default (password: string, salt: number = 10): string => bcryptjs.hashSync(password, salt) diff --git a/src/core/domains/crypto/commands/GenerateAppKey.ts b/src/core/domains/crypto/commands/GenerateAppKey.ts new file mode 100644 index 000000000..913a7b85d --- /dev/null +++ b/src/core/domains/crypto/commands/GenerateAppKey.ts @@ -0,0 +1,35 @@ +import EnvService from "@src/core/services/EnvService"; +import BaseCommand from "@src/core/domains/console/base/BaseCommand"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; + +class GenerateAppKey extends BaseCommand { + + signature = 'app:generate-key' + + description = 'Generate a new app key' + + envService = new EnvService(); + + async execute() { + + const confirm = await this.input.askQuestion('Are you sure you want to generate a new app key? (y/n)') + + if (confirm !== 'y') { + console.log('App key generation cancelled.') + return + } + + console.log('Generating app key...') + + const appKey = cryptoService().generateAppKey() + + await this.envService.updateValues({ + APP_KEY: appKey + }) + + console.log(`App key generated: ${appKey}`) + } + +} + +export default GenerateAppKey diff --git a/src/core/domains/crypto/interfaces/BufferingEncoding.t.ts b/src/core/domains/crypto/interfaces/BufferingEncoding.t.ts new file mode 100644 index 000000000..c00e941a5 --- /dev/null +++ b/src/core/domains/crypto/interfaces/BufferingEncoding.t.ts @@ -0,0 +1,13 @@ +export type BufferEncoding = +| "ascii" +| "utf8" +| "utf-8" +| "utf16le" +| "utf-16le" +| "ucs2" +| "ucs-2" +| "base64" +| "base64url" +| "latin1" +| "binary" +| "hex"; \ No newline at end of file diff --git a/src/core/domains/crypto/interfaces/ICryptoConfig.ts b/src/core/domains/crypto/interfaces/ICryptoConfig.ts new file mode 100644 index 000000000..7971b2aed --- /dev/null +++ b/src/core/domains/crypto/interfaces/ICryptoConfig.ts @@ -0,0 +1,4 @@ +export interface ICryptoConfig { + appKey: string +} + diff --git a/src/core/domains/crypto/interfaces/ICryptoService.ts b/src/core/domains/crypto/interfaces/ICryptoService.ts new file mode 100644 index 000000000..1235c3258 --- /dev/null +++ b/src/core/domains/crypto/interfaces/ICryptoService.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-unused-vars */ +import { BufferEncoding } from "@src/core/domains/crypto/interfaces/BufferingEncoding.t" + +export interface ICryptoService { + generateBytesAsString(length?: number, encoding?: BufferEncoding): string + encrypt(string: string): string + decrypt(string: string): string + hash(string: string): string + verifyHash(string: string, hashWithSalt: string): boolean + generateAppKey(): string +} diff --git a/src/core/domains/crypto/providers/CryptoProvider.ts b/src/core/domains/crypto/providers/CryptoProvider.ts new file mode 100644 index 000000000..45a897451 --- /dev/null +++ b/src/core/domains/crypto/providers/CryptoProvider.ts @@ -0,0 +1,29 @@ +import appConfig, { IAppConfig } from "@src/config/app"; +import BaseProvider from "@src/core/base/Provider"; +import { app } from "@src/core/services/App"; +import GenerateAppKey from "@src/core/domains/crypto/commands/GenerateAppKey"; +import CryptoService from "@src/core/domains/crypto/service/CryptoService"; + +class CryptoProvider extends BaseProvider { + + config: IAppConfig = appConfig; + + async register(): Promise { + + const config = { + appKey: this.config.appKey + } + const cryptoService = new CryptoService(config) + + // Bind the crypto service + this.bind('crypto', cryptoService) + + // Register commands + app('console').register().registerAll([ + GenerateAppKey + ]) + } + +} + +export default CryptoProvider \ No newline at end of file diff --git a/src/core/domains/crypto/service/CryptoService.ts b/src/core/domains/crypto/service/CryptoService.ts new file mode 100644 index 000000000..4797a36eb --- /dev/null +++ b/src/core/domains/crypto/service/CryptoService.ts @@ -0,0 +1,105 @@ +import { app } from "@src/core/services/App" +import crypto from 'crypto' +import { BufferEncoding } from "@src/core/domains/crypto/interfaces/BufferingEncoding.t" +import { ICryptoConfig } from "@src/core/domains/crypto/interfaces/ICryptoConfig" +import { ICryptoService } from "@src/core/domains/crypto/interfaces/ICryptoService" + +// Alias for app('crypto') +export const cryptoService = () => app('crypto') + +class CryptoService implements ICryptoService { + + protected config!: ICryptoConfig; + + constructor(config: ICryptoConfig) { + this.config = config + } + + /** + * Generate a new app key + */ + generateBytesAsString(length: number = 32, encoding: BufferEncoding = 'hex') { + return crypto.randomBytes(length).toString(encoding) + } + + /** + * Encrypt a string + */ + encrypt(toEncrypt: string): string { + this.validateAppKey() + + const iv = crypto.randomBytes(16); + const key = crypto.pbkdf2Sync(this.config.appKey, 'salt', 100000, 32, 'sha256'); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + let encrypted = cipher.update(String(toEncrypt), 'utf-8', 'hex') + encrypted += cipher.final('hex') + return iv.toString('hex') + '|' + encrypted + } + + /** + * Decrypt a string + */ + decrypt(encryptedData: string): string { + this.validateAppKey() + + const [ivHex, encryptedText] = encryptedData.split('|'); + const iv = Buffer.from(ivHex, 'hex'); + const key = crypto.pbkdf2Sync(this.config.appKey, 'salt', 100000, 32, 'sha256'); + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(encryptedText, 'hex', 'utf-8'); + decrypted += decipher.final('utf-8'); + return decrypted; + } + + /** + * Hash a string using PBKDF2 + * @param string The string to hash + * @param salt Optional salt (if not provided, a random one will be generated) + * @returns The hashed string with salt, format: 'salt|hash' + */ + hash(string: string, salt?: string): string { + const useSalt = salt || crypto.randomBytes(16).toString('hex'); + const hashedString = crypto.pbkdf2Sync( + string, + useSalt, + 100000, // iterations + 64, // key length + 'sha512' + ).toString('hex'); + + return `${useSalt}|${hashedString}`; + } + + /** + * Verify a string against a hash + * @param string The string to verify + * @param hashWithSalt The hash with salt (format: 'salt|hash') + * @returns boolean + */ + verifyHash(string: string, hashWithSalt: string): boolean { + const [salt] = hashWithSalt.split('|'); + const verifyHash = this.hash(string, salt); + return verifyHash === hashWithSalt; + } + + /** + * Generate a new app key + */ + generateAppKey(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Validate the app key + */ + private validateAppKey() { + if (!this.config.appKey || this.config.appKey.length === 0) { + throw new Error('App key is not set') + } + } + +} + +export default CryptoService diff --git a/src/core/domains/database/interfaces/IDatabaseService.ts b/src/core/domains/database/interfaces/IDatabaseService.ts index 3a4280cd6..5a55e8363 100644 --- a/src/core/domains/database/interfaces/IDatabaseService.ts +++ b/src/core/domains/database/interfaces/IDatabaseService.ts @@ -5,6 +5,8 @@ import { IDatabaseConfig } from "@src/core/domains/database/interfaces/IDatabase import { IDatabaseSchema } from "@src/core/domains/database/interfaces/IDatabaseSchema"; import { IHasConfigConcern } from "@src/core/interfaces/concerns/IHasConfigConcern"; import { ICtor } from "@src/core/interfaces/ICtor"; +import MongoDbAdapter from "@src/core/domains/mongodb/adapters/MongoDbAdapter"; +import PostgresAdapter from "@src/core/domains/postgres/adapters/PostgresAdapter"; export interface IDatabaseService extends IHasConfigConcern @@ -30,4 +32,8 @@ export interface IDatabaseService extends IHasConfigConcern schema(connectionName?: string): TSchema; createMigrationSchema(tableName: string, connectionName?: string): Promise; + + postgres(): PostgresAdapter; + + mongodb(): MongoDbAdapter; } \ No newline at end of file diff --git a/src/core/domains/database/services/Database.ts b/src/core/domains/database/services/Database.ts index 3dca73af3..6081a1cf7 100644 --- a/src/core/domains/database/services/Database.ts +++ b/src/core/domains/database/services/Database.ts @@ -10,6 +10,8 @@ import { IDatabaseService } from "@src/core/domains/database/interfaces/IDatabas import DatabaseAdapter from "@src/core/domains/database/services/DatabaseAdapter"; import { ICtor } from "@src/core/interfaces/ICtor"; import { App } from "@src/core/services/App"; +import MongoDbAdapter from "@src/core/domains/mongodb/adapters/MongoDbAdapter"; +import PostgresAdapter from "@src/core/domains/postgres/adapters/PostgresAdapter"; /** * Short alias for app('db') @@ -343,6 +345,22 @@ class Database extends BaseSimpleRegister implements IDatabaseService { return await this.getAdapter(connectionName).createMigrationSchema(tableName) } + /** + * Get the postgres adapter + * @returns + */ + postgres(): PostgresAdapter { + return this.getAdapter('postgres') + } + + /** + * Get the mongodb adapter + * @returns + */ + mongodb(): MongoDbAdapter { + return this.getAdapter('mongodb') + } + } export default Database \ No newline at end of file diff --git a/src/core/domains/setup/actions/GenerateAppKeyAction.ts b/src/core/domains/setup/actions/GenerateAppKeyAction.ts new file mode 100644 index 000000000..f771c1acf --- /dev/null +++ b/src/core/domains/setup/actions/GenerateAppKeyAction.ts @@ -0,0 +1,24 @@ +import QuestionDTO from "@src/core/domains/setup/DTOs/QuestionDTO"; +import { IAction } from "@src/core/domains/setup/interfaces/IAction"; +import { ISetupCommand } from "@src/core/domains/setup/interfaces/ISetupCommand"; +import { cryptoService } from "@src/core/domains/crypto/service/CryptoService"; + +class GenerateAppKeyAction implements IAction { + + async handle(ref: ISetupCommand, question: QuestionDTO): Promise { + const answerIsYes = question.getAnswer() === 'y' || question.getAnswer() === 'yes'; + + if(!answerIsYes) { + return; + } + + const appKey = cryptoService().generateAppKey() + + await ref.env.updateValues({ APP_KEY: appKey }); + + ref.writeLine('Successfully generated app key!'); + } + +} + +export default GenerateAppKeyAction \ No newline at end of file diff --git a/src/core/domains/setup/consts/QuestionConsts.ts b/src/core/domains/setup/consts/QuestionConsts.ts index 4eab0b54f..d1a9849ff 100644 --- a/src/core/domains/setup/consts/QuestionConsts.ts +++ b/src/core/domains/setup/consts/QuestionConsts.ts @@ -9,6 +9,7 @@ export const QuestionIDs = { selectDefaultDb: 'SELECT_DEFAULT_DB', copyEnvExample: 'COPY_ENV_EXAMPLE', appPort: 'APP_PORT', + appKey: 'APP_KEY', enableExpress: 'ENABLE_EXPRESS', enableAuthRoutes: 'ENABLE_AUTH_ROUTES', enableAuthRoutesAllowCreate: 'ENABLE_AUTH_ROUTES_ALLOW_CREATE', diff --git a/src/core/domains/setup/utils/buildQuestionDTOs.ts b/src/core/domains/setup/utils/buildQuestionDTOs.ts index 83bd2a23b..4c4928ba5 100644 --- a/src/core/domains/setup/utils/buildQuestionDTOs.ts +++ b/src/core/domains/setup/utils/buildQuestionDTOs.ts @@ -6,6 +6,7 @@ import SetupDefaultDatabase from "@src/core/domains/setup/actions/SetupDefaultDa import SetupDockerDatabaseScripts from "@src/core/domains/setup/actions/SetupDockerDatabaseScripts"; import { QuestionIDs } from "@src/core/domains/setup/consts/QuestionConsts"; import QuestionDTO from "@src/core/domains/setup/DTOs/QuestionDTO"; +import GenerateAppKeyAction from "@src/core/domains/setup/actions/GenerateAppKeyAction"; const ENV_OVERWRITE_WARNING = 'This step will overwrite your .env file.'; const acceptedAnswersBoolean = ['yes', 'no', 'y', 'n', '']; @@ -22,6 +23,14 @@ const buildQuestionDTOs = (): QuestionDTO[] => { previewText: 'Setup Environment File', actionCtor: CopyEnvExampleAction }), + new QuestionDTO({ + id: QuestionIDs.appKey, + question: `Would you like to generate a new app key? ${ENV_OVERWRITE_WARNING}`, + previewText: 'Generate New App Key', + defaultValue: 'yes', + acceptedAnswers: acceptedAnswersBoolean, + actionCtor: GenerateAppKeyAction, + }), new QuestionDTO({ id: QuestionIDs.jwtSecret, question: `Would you like to generate a new JWT secret? ${ENV_OVERWRITE_WARNING}`, diff --git a/src/core/interfaces/ILarascriptProviders.ts b/src/core/interfaces/ILarascriptProviders.ts index 1ea150fc2..69694a7d9 100644 --- a/src/core/interfaces/ILarascriptProviders.ts +++ b/src/core/interfaces/ILarascriptProviders.ts @@ -8,8 +8,9 @@ import { IEventService } from '@src/core/domains/events/interfaces/IEventService import IHttpService from '@src/core/domains/http/interfaces/IHttpService'; import { IRequestContext } from '@src/core/domains/http/interfaces/IRequestContext'; import { ILoggerService } from '@src/core/domains/logger/interfaces/ILoggerService'; -import readline from 'node:readline'; import { IValidatorMake } from '@src/core/domains/validator/interfaces/IValidator'; +import readline from 'node:readline'; +import { ICryptoService } from '@src/core/domains/crypto/interfaces/ICryptoService'; export interface ILarascriptProviders { @@ -90,4 +91,10 @@ export interface ILarascriptProviders { * Provided by '@src/core/domains/logger/providers/LoggerProvider' */ logger: ILoggerService; + + /** + * Crypto service + * Provided by '@src/core/domains/crypto/providers/CryptoProvider' + */ + crypto: ICryptoService; } diff --git a/src/core/models/base/Model.ts b/src/core/models/base/Model.ts index 008e16711..e79250e11 100644 --- a/src/core/models/base/Model.ts +++ b/src/core/models/base/Model.ts @@ -1,15 +1,17 @@ +import { cryptoService } from '@src/core/domains/crypto/service/CryptoService'; import { IDatabaseSchema } from '@src/core/domains/database/interfaces/IDatabaseSchema'; import { db } from '@src/core/domains/database/services/Database'; import BaseRelationshipResolver from '@src/core/domains/eloquent/base/BaseRelationshipResolver'; import { IBelongsToOptions, IEloquent, IHasManyOptions, IRelationship, IdGeneratorFn } from '@src/core/domains/eloquent/interfaces/IEloquent'; import BelongsTo from '@src/core/domains/eloquent/relational/BelongsTo'; import HasMany from '@src/core/domains/eloquent/relational/HasMany'; +import { queryBuilder } from '@src/core/domains/eloquent/services/EloquentQueryBuilderService'; import ModelScopes, { TModelScope } from '@src/core/domains/models/utils/ModelScope'; import { ObserveConstructor } from '@src/core/domains/observer/interfaces/IHasObserver'; import { IObserver, IObserverEvent } from '@src/core/domains/observer/interfaces/IObserver'; import { ICtor } from '@src/core/interfaces/ICtor'; import IFactory, { FactoryConstructor } from '@src/core/interfaces/IFactory'; -import { GetAttributesOptions, IModel, IModelAttributes, ModelConstructor } from "@src/core/interfaces/IModel"; +import { GetAttributesOptions, IModel, IModelAttributes, ModelConstructor, ModelWithAttributes } from "@src/core/interfaces/IModel"; import ProxyModelHandler from '@src/core/models/utils/ProxyModelHandler'; import { app } from '@src/core/services/App'; import Str from '@src/core/util/str/Str'; @@ -86,6 +88,11 @@ export default abstract class Model impleme */ public relationships: string[] = []; + /** + * List of fields that should be encrypted. + */ + public encrypted: string[] = []; + /** * The name of the database connection to use. * Defaults to the application's default connection name. @@ -184,7 +191,7 @@ export default abstract class Model impleme * @param {Model['attributes'] | null} data - The initial data to populate the model. * @returns {Model} A new instance of the model wrapped in a Proxy. */ - static create(data: Model['attributes'] | null = null): Model { + static create(data: Partial | null = null): ModelWithAttributes { return new Proxy( new (this as unknown as ICtor)(data), new ProxyModelHandler() @@ -255,6 +262,14 @@ export default abstract class Model impleme return this.create().getFactory() } + /** + * Retrieves the query builder for the model. + * @returns The query builder for the model. + */ + static query(): IEloquent { + return queryBuilder(this as unknown as ModelConstructor) as IEloquent + } + /** * Retrieves the factory instance for the model. * @returns The factory instance for the model. @@ -409,6 +424,10 @@ export default abstract class Model impleme * @returns {Attributes[K] | null} The value of the attribute or null if not found. */ getAttributeSync(key: K): Attributes[K] | null { + if(this.encrypted.includes(key as string)) { + return this.decryptAttributes({[key]: this.attributes?.[key]} as Attributes)?.[key] ?? null; + } + return this.attributes?.[key] ?? null; } @@ -670,13 +689,65 @@ export default abstract class Model impleme const result = await this.queryBuilder().find(id) const attributes = result ? await result.toObject() : null; + const decryptedAttributes = await this.decryptAttributes(attributes as Attributes | null); - this.attributes = attributes ? { ...attributes } as Attributes : null + this.attributes = decryptedAttributes ? { ...decryptedAttributes } as Attributes : null this.original = { ...(this.attributes ?? {}) } as Attributes return this.attributes as Attributes; } + /** + * Encrypts the attributes of the model. + * + * @param {Attributes} attributes - The attributes to encrypt. + * @returns {Promise} The encrypted attributes. + */ + encryptAttributes(attributes: Attributes | null): Attributes | null { + if(typeof attributes !== 'object') { + return attributes; + } + + this.encrypted.forEach(key => { + if(typeof attributes?.[key] !== 'undefined' && attributes?.[key] !== null) { + try { + (attributes as object)[key] = cryptoService().encrypt((attributes as object)[key]); + } + catch (e) { + console.error(e) + } + } + }); + + return attributes; + } + + /** + * Decrypts the attributes of the model. + * + * @param {Attributes} attributes - The attributes to decrypt. + * @returns {Promise} The decrypted attributes. + */ + decryptAttributes(attributes: Attributes | null): Attributes | null { + if(typeof this.attributes !== 'object') { + return attributes; + } + + this.encrypted.forEach(key => { + if(typeof attributes?.[key] !== 'undefined' && attributes?.[key] !== null) { + try { + (attributes as object)[key] = cryptoService().decrypt((attributes as object)[key]); + } + + catch (e) { + console.error(e) + } + } + }); + + return attributes; + } + /** * Updates the model in the database. * @@ -687,7 +758,8 @@ export default abstract class Model impleme const builder = this.queryBuilder() const normalizedIdProperty = builder.normalizeIdProperty(this.primaryKey) - await builder.where(normalizedIdProperty, this.getId()).update({...this.attributes}); + const encryptedAttributes = await this.encryptAttributes(this.attributes) + await builder.where(normalizedIdProperty, this.getId()).update({...encryptedAttributes}); } @@ -703,7 +775,8 @@ export default abstract class Model impleme await this.setTimestamp('createdAt'); await this.setTimestamp('updatedAt'); - this.attributes = await (await this.queryBuilder().insert(this.attributes as object)).first()?.toObject() as Attributes; + const encryptedAttributes = await this.encryptAttributes(this.attributes) + this.attributes = await (await this.queryBuilder().insert(encryptedAttributes as object)).first()?.toObject() as Attributes; this.attributes = await this.refresh(); this.attributes = await this.observeAttributes('created', this.attributes); return; diff --git a/src/core/providers/LarascriptProviders.ts b/src/core/providers/LarascriptProviders.ts index 13999d71c..f26173ef9 100644 --- a/src/core/providers/LarascriptProviders.ts +++ b/src/core/providers/LarascriptProviders.ts @@ -12,6 +12,7 @@ import ValidatorProvider from "@src/core/domains/validator/providers/ValidatorPr // eslint-disable-next-line no-unused-vars import { ILarascriptProviders } from "@src/core/interfaces/ILarascriptProviders"; import { IProvider } from "@src/core/interfaces/IProvider"; +import CryptoProvider from "@src/core/domains/crypto/providers/CryptoProvider"; /** @@ -94,6 +95,13 @@ const LarascriptProviders: IProvider[] = [ */ new ValidatorProvider(), + /** + * Crypto provider + * + * Provides crypto services + */ + new CryptoProvider(), + /** * Setup provider * diff --git a/src/setup.ts b/src/setup.ts index 16e7d3677..1b262e172 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -7,6 +7,7 @@ import LoggerProvider from '@src/core/domains/logger/providers/LoggerProvider'; import SetupProvider from '@src/core/domains/setup/providers/SetupProvider'; import Kernel from "@src/core/Kernel"; import { App } from '@src/core/services/App'; +import CryptoProvider from '@src/core/domains/crypto/providers/CryptoProvider'; (async() => { require('dotenv').config(); @@ -18,6 +19,7 @@ import { App } from '@src/core/services/App'; new LoggerProvider(), new ConsoleProvider(), new DatabaseRegisterOnlyProvider(), + new CryptoProvider(), new SetupProvider() ] }, {}) diff --git a/src/tests/auth/authLoginUser.test.ts b/src/tests/auth/authLoginUser.test.ts index cced9560b..223598d4e 100644 --- a/src/tests/auth/authLoginUser.test.ts +++ b/src/tests/auth/authLoginUser.test.ts @@ -3,7 +3,7 @@ import { describe } from '@jest/globals'; import { IApiTokenModel } from '@src/core/domains/auth/interfaces/models/IApiTokenModel'; import { IUserModel } from '@src/core/domains/auth/interfaces/models/IUserModel'; import { auth } from '@src/core/domains/auth/services/AuthService'; -import hashPassword from '@src/core/domains/auth/utils/hashPassword'; +import { cryptoService } from '@src/core/domains/crypto/service/CryptoService'; import { logger } from '@src/core/domains/logger/services/LoggerService'; import TestApiTokenModel from '@src/tests/models/models/TestApiTokenModel'; import TestUser from '@src/tests/models/models/TestUser'; @@ -17,13 +17,16 @@ describe('attempt to run app with normal appConfig', () => { let testUser: IUserModel; const email = 'testUser@test.com'; const password = 'testPassword'; - const hashedPassword = hashPassword(password); + let hashedPassword: string; let jwtToken: string; let apiToken: IApiTokenModel | null; beforeAll(async () => { await testHelper.testBootApp(); + // Hash the password (cryptoService is only available after app boot) + hashedPassword = await cryptoService().hash(password); + try { await testHelper.dropAuthTables(); } diff --git a/src/tests/crypto/crypto.test.ts b/src/tests/crypto/crypto.test.ts new file mode 100644 index 000000000..821ba0414 --- /dev/null +++ b/src/tests/crypto/crypto.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-undef */ +import { describe } from '@jest/globals'; +import { cryptoService } from '@src/core/domains/crypto/service/CryptoService'; +import testHelper from '@src/tests/testHelper'; + + + +describe('test crypto', () => { + + beforeAll(async () => { + await testHelper.testBootApp() + }) + + test('test encryption and decryption', async () => { + const plaintext = 'Hello World'; + + // Test encryption + const encrypted = await cryptoService().encrypt(plaintext); + expect(encrypted).toBeDefined(); + expect(encrypted.includes('|')).toBeTruthy(); // Should contain IV separator + + // Test decryption + const decrypted = await cryptoService().decrypt(encrypted); + expect(decrypted).toBe(plaintext); + }); + + test('test hashing and verification', async () => { + const plaintext = 'Hello World'; + + // Test hashing + const hashed = await cryptoService().hash(plaintext); + expect(hashed).toBeDefined(); + expect(hashed.includes('|')).toBeTruthy(); // Should contain salt separator + + // Test hash verification + const isValid = await cryptoService().verifyHash(plaintext, hashed); + expect(isValid).toBeTruthy(); + + // Test invalid hash verification + const isInvalid = await cryptoService().verifyHash('wrong password', hashed); + expect(isInvalid).toBeFalsy(); + }); + + test('encryption produces different ciphertexts for same input', async () => { + const plaintext = 'Hello World'; + + const encrypted1 = await cryptoService().encrypt(plaintext); + const encrypted2 = await cryptoService().encrypt(plaintext); + + expect(encrypted1).not.toBe(encrypted2); // Should be different due to random IV + + // But both should decrypt to the same plaintext + const decrypted1 = await cryptoService().decrypt(encrypted1); + const decrypted2 = await cryptoService().decrypt(encrypted2); + expect(decrypted1).toBe(decrypted2); + }); + +}); \ No newline at end of file diff --git a/src/tests/models/modelEncryption.test.ts b/src/tests/models/modelEncryption.test.ts new file mode 100644 index 000000000..d51c9aed1 --- /dev/null +++ b/src/tests/models/modelEncryption.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-undef */ +import { describe, expect, test } from '@jest/globals'; +import Model from '@src/core/models/base/Model'; +import testHelper from '@src/tests/testHelper'; +import TestEncryptionModel, { TestEncryptionModelAttributes, resetEncryptionTable } from '@src/tests/models/models/TestEncryptionModel'; + +describe('test model encryption', () => { + beforeAll(async () => { + await testHelper.testBootApp() + }) + + beforeEach(async () => { + await resetEncryptionTable() + }) + + test('encrypt and decrypt field when saving and retrieving', async () => { + // Create a model with a secret value + const secretValue = 'my-super-secret-value' + const created = await TestEncryptionModel.create({ + secret: secretValue + }) + await created.save() + + // Verify the stored value is encrypted (different from original) + const mockEncryptedAttributes = (created as Model).encryptAttributes({ + secret: secretValue + } as TestEncryptionModelAttributes) + expect(mockEncryptedAttributes?.secret).not.toBe(secretValue) + + // Retrieve the model from database + const retrieved = await TestEncryptionModel.query().find(created.id) + + // Verify the decrypted value matches original + expect(retrieved?.secret).toBe(secretValue) + }) + + test('updates encrypted field correctly', async () => { + // Create initial model + const created = await TestEncryptionModel.create({ + secret: 'initial-secret' + }) + await created.save() + + // Update the secret + const newSecret = 'updated-secret' + await created.setAttribute('secret', newSecret) + await created.save() + + // Retrieve and verify updated value + const retrieved = await TestEncryptionModel.query().find(created.id) + expect(retrieved?.secret).toBe(newSecret) + }) + + test('handles null values in encrypted fields', async () => { + const created = await TestEncryptionModel.create({ + secret: null + }) + await created.save() + + const retrieved = await TestEncryptionModel.query().find(created.id) + expect(retrieved?.secret).toBeNull() + }) +}); diff --git a/src/tests/models/models/TestEncryptionModel.ts b/src/tests/models/models/TestEncryptionModel.ts new file mode 100644 index 000000000..e94aa99b6 --- /dev/null +++ b/src/tests/models/models/TestEncryptionModel.ts @@ -0,0 +1,46 @@ +import { IModelAttributes } from "@src/core/interfaces/IModel"; +import Model from "@src/core/models/base/Model"; +import { App } from "@src/core/services/App"; +import { forEveryConnection } from "@src/tests/testHelper"; +import { DataTypes } from "sequelize"; + +export interface TestEncryptionModelAttributes extends IModelAttributes { + id: string + secret: string | null + createdAt: Date + updatedAt: Date +} + +export const resetEncryptionTable = async () => { + const tableName = TestEncryptionModel.getTable() + + await forEveryConnection(async connectionName => { + const schema = App.container('db').schema(connectionName); + + if(await schema.tableExists(tableName)) { + await schema.dropTable(tableName); + } + + await schema.createTable(tableName, { + secret: DataTypes.STRING, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }) + }) +} + +class TestEncryptionModel extends Model { + + public table: string = 'testsEncryption'; + + public fields: string[] = [ + 'secret', + 'createdAt', + 'updatedAt' + ] + + public encrypted: string[] = ['secret'] + +} + +export default TestEncryptionModel \ No newline at end of file diff --git a/src/tests/providers/TestCryptoProvider.ts b/src/tests/providers/TestCryptoProvider.ts new file mode 100644 index 000000000..b1f5b7dd9 --- /dev/null +++ b/src/tests/providers/TestCryptoProvider.ts @@ -0,0 +1,14 @@ +import { IAppConfig } from "@src/config/app"; +import { EnvironmentTesting } from "@src/core/consts/Environment"; +import CryptoProvider from "@src/core/domains/crypto/providers/CryptoProvider"; + +class TestCryptoProvider extends CryptoProvider { + + config: IAppConfig = { + env: EnvironmentTesting, + appKey: 'test-app-key' + } + +} + +export default TestCryptoProvider \ No newline at end of file diff --git a/src/tests/testHelper.ts b/src/tests/testHelper.ts index f757ee097..f08a30cfd 100644 --- a/src/tests/testHelper.ts +++ b/src/tests/testHelper.ts @@ -12,6 +12,7 @@ import TestDatabaseProvider, { testDbName } from "@src/tests/providers/TestDatab import TestEventProvider from "@src/tests/providers/TestEventProvider"; import TestMigrationProvider from "@src/tests/providers/TestMigrationProvider"; import { DataTypes } from "sequelize"; +import TestCryptoProvider from "@src/tests/providers/TestCryptoProvider"; export const getTestDbName = () => testDbName @@ -35,7 +36,8 @@ const testBootApp = async () => { new TestEventProvider(), new TestAuthProvider(), new TestMigrationProvider(), - new ValidatorProvider() + new ValidatorProvider(), + new TestCryptoProvider() ] }