From f30a7b287802e17eddd298ec455561f5188c05c7 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 19 Jun 2025 11:11:52 +0200 Subject: [PATCH] feat(session): Implement token hashing and device association for session management --- apps/web/migrations/0001_mute_flatman.sql | 2 + apps/web/migrations/meta/0001_snapshot.json | 1338 +++++++++++++++++ apps/web/migrations/meta/_journal.json | 7 + apps/web/src/vitnode.api.config.ts | 12 +- .../src/api/middlewares/global/global.ts | 4 - packages/vitnode/src/api/models/device.ts | 28 +- packages/vitnode/src/api/models/password.ts | 11 +- .../vitnode/src/api/models/session-admin.ts | 64 +- packages/vitnode/src/api/models/session.ts | 57 +- packages/vitnode/src/database/sessions.ts | 1 + 10 files changed, 1476 insertions(+), 48 deletions(-) create mode 100644 apps/web/migrations/0001_mute_flatman.sql create mode 100644 apps/web/migrations/meta/0001_snapshot.json diff --git a/apps/web/migrations/0001_mute_flatman.sql b/apps/web/migrations/0001_mute_flatman.sql new file mode 100644 index 000000000..d2de99678 --- /dev/null +++ b/apps/web/migrations/0001_mute_flatman.sql @@ -0,0 +1,2 @@ +ALTER TABLE "core_sessions_known_devices" ADD COLUMN "publicId" varchar(32) NOT NULL;--> statement-breakpoint +ALTER TABLE "core_sessions_known_devices" ADD CONSTRAINT "core_sessions_known_devices_publicId_unique" UNIQUE("publicId"); \ No newline at end of file diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..26e4c85b4 --- /dev/null +++ b/apps/web/migrations/meta/0001_snapshot.json @@ -0,0 +1,1338 @@ +{ + "id": "147890d3-87ad-4e29-a83e-43b220c8af80", + "prevId": "d8197fc3-2d92-4d22-9381-9ebb1412c8fc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.core_admin_permissions": { + "name": "core_admin_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_admin_permissions_role_id_idx": { + "name": "core_admin_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_permissions_user_id_idx": { + "name": "core_admin_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_permissions_roleId_core_roles_id_fk": { + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_permissions_userId_core_users_id_fk": { + "name": "core_admin_permissions_userId_core_users_id_fk", + "tableFrom": "core_admin_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_admin_sessions": { + "name": "core_admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_admin_sessions_token_idx": { + "name": "core_admin_sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_admin_sessions_user_id_idx": { + "name": "core_admin_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_admin_sessions_userId_core_users_id_fk": { + "name": "core_admin_sessions_userId_core_users_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_admin_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_admin_sessions_token_unique": { + "name": "core_admin_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages": { + "name": "core_languages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "time24": { + "name": "time24", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_languages_code_idx": { + "name": "core_languages_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_languages_name_idx": { + "name": "core_languages_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_languages_code_unique": { + "name": "core_languages_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_languages_words": { + "name": "core_languages_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "languageCode": { + "name": "languageCode", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "pluginCode": { + "name": "pluginCode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "itemId": { + "name": "itemId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tableName": { + "name": "tableName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "variable": { + "name": "variable", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_languages_words_lang_code_idx": { + "name": "core_languages_words_lang_code_idx", + "columns": [ + { + "expression": "languageCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_languages_words_languageCode_core_languages_code_fk": { + "name": "core_languages_words_languageCode_core_languages_code_fk", + "tableFrom": "core_languages_words", + "tableTo": "core_languages", + "columnsFrom": [ + "languageCode" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_moderators_permissions": { + "name": "core_moderators_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "core_moderators_permissions_role_id_idx": { + "name": "core_moderators_permissions_role_id_idx", + "columns": [ + { + "expression": "roleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_moderators_permissions_user_id_idx": { + "name": "core_moderators_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_moderators_permissions_roleId_core_roles_id_fk": { + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_moderators_permissions_userId_core_users_id_fk": { + "name": "core_moderators_permissions_userId_core_users_id_fk", + "tableFrom": "core_moderators_permissions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_roles": { + "name": "core_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default": { + "name": "default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "root": { + "name": "root", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "guest": { + "name": "guest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "varchar(19)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions": { + "name": "core_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_sessions_user_id_idx": { + "name": "core_sessions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_sessions_userId_core_users_id_fk": { + "name": "core_sessions_userId_core_users_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "core_sessions_deviceId_core_sessions_known_devices_id_fk": { + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "tableFrom": "core_sessions", + "tableTo": "core_sessions_known_devices", + "columnsFrom": [ + "deviceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_token_unique": { + "name": "core_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_sessions_known_devices": { + "name": "core_sessions_known_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastSeen": { + "name": "lastSeen", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "core_sessions_known_devices_ip_address_idx": { + "name": "core_sessions_known_devices_ip_address_idx", + "columns": [ + { + "expression": "ipAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_sessions_known_devices_publicId_unique": { + "name": "core_sessions_known_devices_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users": { + "name": "core_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nameCode": { + "name": "nameCode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatarColor": { + "name": "avatarColor", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "roleId": { + "name": "roleId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'en'" + } + }, + "indexes": { + "core_users_name_code_idx": { + "name": "core_users_name_code_idx", + "columns": [ + { + "expression": "nameCode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_name_idx": { + "name": "core_users_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "core_users_email_idx": { + "name": "core_users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_roleId_core_roles_id_fk": { + "name": "core_users_roleId_core_roles_id_fk", + "tableFrom": "core_users", + "tableTo": "core_roles", + "columnsFrom": [ + "roleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "core_users_language_core_languages_code_fk": { + "name": "core_users_language_core_languages_code_fk", + "tableFrom": "core_users", + "tableTo": "core_languages", + "columnsFrom": [ + "language" + ], + "columnsTo": [ + "code" + ], + "onDelete": "set default", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_nameCode_unique": { + "name": "core_users_nameCode_unique", + "nullsNotDistinct": false, + "columns": [ + "nameCode" + ] + }, + "core_users_name_unique": { + "name": "core_users_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "core_users_email_unique": { + "name": "core_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_confirm_emails": { + "name": "core_users_confirm_emails", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_confirm_emails_userId_core_users_id_fk": { + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "tableFrom": "core_users_confirm_emails", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_confirm_emails_token_unique": { + "name": "core_users_confirm_emails_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_forgot_password": { + "name": "core_users_forgot_password", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "core_users_forgot_password_userId_core_users_id_fk": { + "name": "core_users_forgot_password_userId_core_users_id_fk", + "tableFrom": "core_users_forgot_password", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "core_users_forgot_password_userId_unique": { + "name": "core_users_forgot_password_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "core_users_forgot_password_token_unique": { + "name": "core_users_forgot_password_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.core_users_sso": { + "name": "core_users_sso", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "core_users_sso_user_id_idx": { + "name": "core_users_sso_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "core_users_sso_userId_core_users_id_fk": { + "name": "core_users_sso_userId_core_users_id_fk", + "tableFrom": "core_users_sso", + "tableTo": "core_users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "titleSeo": { + "name": "titleSeo", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_categories_titleSeo_unique": { + "name": "blog_categories_titleSeo_unique", + "nullsNotDistinct": false, + "columns": [ + "titleSeo" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "titleSeo": { + "name": "titleSeo", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categoryId": { + "name": "categoryId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "blog_posts_categoryId_blog_categories_id_fk": { + "name": "blog_posts_categoryId_blog_categories_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_categories", + "columnsFrom": [ + "categoryId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_titleSeo_unique": { + "name": "blog_posts_titleSeo_unique", + "nullsNotDistinct": false, + "columns": [ + "titleSeo" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index 9fca9e9ae..600ef136a 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1749828219011, "tag": "0000_overjoyed_sandman", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1750322132661, + "tag": "0001_mute_flatman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index bcd8b48e6..748a4fa60 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -22,12 +22,12 @@ export const vitNodeApiConfig = buildApiConfig({ connection: POSTGRES_URL, casing: 'camelCase', }), - emailProvider: NodemailerEmailPlugin({ - from: process.env.NODE_MAILER_FROM, - host: process.env.NODE_MAILER_HOST, - password: process.env.NODE_MAILER_PASSWORD, - user: process.env.NOD_EMAILER_USER, - }), + // emailProvider: NodemailerEmailPlugin({ + // from: process.env.NODE_MAILER_FROM, + // host: process.env.NODE_MAILER_HOST, + // password: process.env.NODE_MAILER_PASSWORD, + // user: process.env.NOD_EMAILER_USER, + // }), authorization: { ssoProviders: [ DiscordSSOApiPlugin({ diff --git a/packages/vitnode/src/api/middlewares/global/global.ts b/packages/vitnode/src/api/middlewares/global/global.ts index 545206fc9..bad5dec0f 100644 --- a/packages/vitnode/src/api/middlewares/global/global.ts +++ b/packages/vitnode/src/api/middlewares/global/global.ts @@ -5,7 +5,6 @@ import { HTTPException } from 'hono/http-exception'; import type { EmailApiPlugin } from '@/api/models/email'; import type { VitNodeApiConfig, VitNodeConfig } from '@/vitnode.config'; -import { DeviceModel } from '@/api/models/device'; import { SessionModel } from '@/api/models/session'; import { SessionAdminModel } from '@/api/models/session-admin'; @@ -48,7 +47,6 @@ interface EnvVariablesVitNode { }; }; db: Pick['dbProvider']; - deviceId: number; plugin: { id: string; }; @@ -99,8 +97,6 @@ export const globalMiddleware = ({ }, }); - const deviceId = await new DeviceModel(c).getDeviceId(); - c.set('deviceId', deviceId); const user = await new SessionModel(c).getUser(); c.set('user', user); c.set('admin', null); diff --git a/packages/vitnode/src/api/models/device.ts b/packages/vitnode/src/api/models/device.ts index 7be6dcefa..5d7fa160a 100644 --- a/packages/vitnode/src/api/models/device.ts +++ b/packages/vitnode/src/api/models/device.ts @@ -1,5 +1,6 @@ import type { Context } from 'hono'; +import { randomBytes } from 'crypto'; import { eq } from 'drizzle-orm'; import { getCookie, setCookie } from 'hono/cookie'; @@ -15,29 +16,32 @@ export class DeviceModel { protected readonly c: Context; private async createDevice() { + const publicId = randomBytes(16).toString('hex'); + const [device] = await this.c .get('db') .insert(core_sessions_known_devices) .values({ + publicId, ipAddress: getUserIp(this.c), userAgent: this.getUserAgent(), }) .returning({ id: core_sessions_known_devices.id }); - this.setCookieDevice(device.id); + this.setCookieDevice(publicId); - return device.id; + return { id: device.id, publicId }; } private getUserAgent() { return this.c.req.header('User-Agent') ?? 'node'; } - private setCookieDevice(deviceId: number) { + private setCookieDevice(publicDeviceId: string) { setCookie( this.c, this.c.get('core').authorization.deviceCookieName, - deviceId.toString(), + publicDeviceId, { httpOnly: true, secure: this.c.get('core').authorization.cookieSecure, @@ -51,8 +55,9 @@ export class DeviceModel { } async getDeviceId() { - const deviceIdFromCookie = Number( - getCookie(this.c, this.c.get('core').authorization.deviceCookieName), + const deviceIdFromCookie = getCookie( + this.c, + this.c.get('core').authorization.deviceCookieName, ); try { @@ -63,7 +68,7 @@ export class DeviceModel { id: core_sessions_known_devices.id, }) .from(core_sessions_known_devices) - .where(eq(core_sessions_known_devices.id, deviceIdFromCookie)); + .where(eq(core_sessions_known_devices.publicId, deviceIdFromCookie)); if (!device) { return await this.createDevice(); @@ -76,12 +81,15 @@ export class DeviceModel { ipAddress: getUserIp(this.c), userAgent: this.getUserAgent(), }) - .where(eq(core_sessions_known_devices.id, deviceIdFromCookie)); + .where(eq(core_sessions_known_devices.publicId, deviceIdFromCookie)); - return device.id; + return { + id: device.id, + publicId: deviceIdFromCookie, + }; } } catch (_) { - return await this.createDevice(); + /* empty */ } return await this.createDevice(); diff --git a/packages/vitnode/src/api/models/password.ts b/packages/vitnode/src/api/models/password.ts index 56ce17940..afe91ec53 100644 --- a/packages/vitnode/src/api/models/password.ts +++ b/packages/vitnode/src/api/models/password.ts @@ -16,9 +16,18 @@ export class PasswordModel { async verifyPassword(password: string, hash: string): Promise { return new Promise((resolve, reject) => { const [salt, key] = hash.split(':'); + const keyBuffer = Buffer.from(key, 'hex'); + crypto.scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); - resolve(key == derivedKey.toString('hex')); + + if (keyBuffer.length !== derivedKey.length) { + crypto.timingSafeEqual(derivedKey, derivedKey); + resolve(false); + } else { + const areEqual = crypto.timingSafeEqual(keyBuffer, derivedKey); + resolve(areEqual); + } }); }); } diff --git a/packages/vitnode/src/api/models/session-admin.ts b/packages/vitnode/src/api/models/session-admin.ts index 4573147f4..79269c038 100644 --- a/packages/vitnode/src/api/models/session-admin.ts +++ b/packages/vitnode/src/api/models/session-admin.ts @@ -1,12 +1,13 @@ import type { Context } from 'hono'; -import { and, eq, gt, or } from 'drizzle-orm'; +import { and, eq, gt, or } from 'drizzle-orm'; // Removed 'or' as it's safer not to use it here import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { HTTPException } from 'hono/http-exception'; import { core_admin_permissions, core_admin_sessions } from '@/database/admins'; import { CONFIG } from '@/lib/config'; +import { DeviceModel } from './device'; import { UserModel } from './user'; export class SessionAdminModel { @@ -15,6 +16,15 @@ export class SessionAdminModel { } protected readonly c: Context; + private async hashToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } + async checkIfUserIsAdmin(userId: number) { const user = await new UserModel().getUserById({ id: userId, c: this.c }); if (!user) return false; @@ -36,26 +46,29 @@ export class SessionAdminModel { async createSessionByUserId(userId: number) { const isAdmin = await this.checkIfUserIsAdmin(userId); - if (!isAdmin) throw new HTTPException(403); + if (!isAdmin) { + throw new HTTPException(403, { message: 'Forbidden' }); + } - // Generate secure random bytes using Web Crypto API const randomBytes = new Uint8Array(64); crypto.getRandomValues(randomBytes); const token = Array.from(randomBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); - const deviceId = this.c.get('deviceId'); + + const hashedToken = await this.hashToken(token); + const device = await new DeviceModel(this.c).getDeviceId(); await this.c .get('db') .insert(core_admin_sessions) .values({ - token, + token: hashedToken, userId, expiresAt: new Date( Date.now() + this.c.get('core').authorization.adminCookieExpires, ), - deviceId, + deviceId: device.id, }); setCookie(this.c, this.c.get('core').authorization.adminCookieName, token, { @@ -68,7 +81,7 @@ export class SessionAdminModel { domain: CONFIG.frontend.hostname, }); - return { token, deviceId }; + return { token }; } async deleteSession() { @@ -78,12 +91,16 @@ export class SessionAdminModel { ); if (!token) return; + const hashedToken = await this.hashToken(token); + await this.c .get('db') .delete(core_admin_sessions) - .where(eq(core_admin_sessions.token, token)); + .where(eq(core_admin_sessions.token, hashedToken)); + deleteCookie(this.c, this.c.get('core').authorization.adminCookieName, { - path: '/admin', + path: '/', + domain: CONFIG.frontend.hostname, }); } @@ -91,31 +108,48 @@ export class SessionAdminModel { const { authorization } = this.c.get('core'); const token = getCookie(this.c, authorization.adminCookieName); if (!token) return null; - const deviceId = this.c.get('deviceId'); - if (!deviceId) return null; + + const device = await new DeviceModel(this.c).getDeviceId(); + if (!device) return null; + + const hashedToken = await this.hashToken(token); const [session] = await this.c .get('db') .select({ - token: core_admin_sessions.token, userId: core_admin_sessions.userId, }) .from(core_admin_sessions) .where( and( - eq(core_admin_sessions.token, token), - eq(core_admin_sessions.deviceId, deviceId), + eq(core_admin_sessions.token, hashedToken), + eq(core_admin_sessions.deviceId, device.id), gt(core_admin_sessions.expiresAt, new Date()), ), ) .limit(1); - if (!session) return null; + if (!session) { + deleteCookie(this.c, this.c.get('core').authorization.adminCookieName, { + path: '/', + domain: CONFIG.frontend.hostname, + }); + + return null; + } + const user = await new UserModel().getUserById({ id: session.userId, c: this.c, }); + if (!user) return null; + const isStillAdmin = await this.checkIfUserIsAdmin(user.id); + if (!isStillAdmin) { + await this.deleteSession(); + + return null; + } return user; } diff --git a/packages/vitnode/src/api/models/session.ts b/packages/vitnode/src/api/models/session.ts index 3fe24dc9e..3849fb4db 100644 --- a/packages/vitnode/src/api/models/session.ts +++ b/packages/vitnode/src/api/models/session.ts @@ -6,6 +6,7 @@ import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { core_sessions } from '@/database/sessions'; import { CONFIG } from '@/lib/config'; +import { DeviceModel } from './device'; import { UserModel } from './user'; export class SessionModel { @@ -14,6 +15,15 @@ export class SessionModel { } protected readonly c: Context; + private async hashToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + } + async createSessionByUserId(userId: number) { // Generate secure random bytes using Web Crypto API const randomBytes = new Uint8Array(64); @@ -21,18 +31,19 @@ export class SessionModel { const token = Array.from(randomBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); - const deviceId = this.c.get('deviceId'); + const device = await new DeviceModel(this.c).getDeviceId(); + const hashedToken = await this.hashToken(token); await this.c .get('db') .insert(core_sessions) .values({ - token, + token: hashedToken, userId, expiresAt: new Date( Date.now() + this.c.get('core').authorization.cookie_expires, ), - deviceId, + deviceId: device.id, }); setCookie(this.c, this.c.get('core').authorization.cookieName, token, { @@ -48,7 +59,7 @@ export class SessionModel { domain: CONFIG.frontend.hostname, }); - return { token, deviceId }; + return { token }; } async deleteSession() { @@ -56,12 +67,28 @@ export class SessionModel { this.c, this.c.get('core').authorization.cookieName, ); - if (!token) return; + const device = await new DeviceModel(this.c).getDeviceId(); + + // Ensure both token and deviceId exist before proceeding + if (!token || !device.id) { + deleteCookie(this.c, this.c.get('core').authorization.cookieName); + + return; + } + + const hashedToken = await this.hashToken(token); await this.c .get('db') .delete(core_sessions) - .where(eq(core_sessions.token, token)); + // Harden the query to ensure a user can only delete their own device's session + .where( + and( + eq(core_sessions.token, hashedToken), + eq(core_sessions.deviceId, device.id), + ), + ); + deleteCookie(this.c, this.c.get('core').authorization.cookieName); } @@ -71,32 +98,38 @@ export class SessionModel { this.c.get('core').authorization.cookieName, ); if (!token) return null; - const deviceId = this.c.get('deviceId'); - if (!deviceId) return null; + + const device = await new DeviceModel(this.c).getDeviceId(); + if (!device) return null; + + const hashedToken = await this.hashToken(token); const [session] = await this.c .get('db') .select({ - token: core_sessions.token, userId: core_sessions.userId, }) .from(core_sessions) .where( and( - eq(core_sessions.token, token), - eq(core_sessions.deviceId, deviceId), + eq(core_sessions.token, hashedToken), + eq(core_sessions.deviceId, device.id), gt(core_sessions.expiresAt, new Date()), ), ) .limit(1); - if (!session || session.token !== token) { + if (!session) { + deleteCookie(this.c, this.c.get('core').authorization.cookieName); + return null; } + const user = await new UserModel().getUserById({ id: session.userId, c: this.c, }); + if (!user) return null; return user; diff --git a/packages/vitnode/src/database/sessions.ts b/packages/vitnode/src/database/sessions.ts index 5506b2f4c..7f3ecf4f9 100644 --- a/packages/vitnode/src/database/sessions.ts +++ b/packages/vitnode/src/database/sessions.ts @@ -41,6 +41,7 @@ export const core_sessions_known_devices = pgTable( 'core_sessions_known_devices', t => ({ id: t.serial().primaryKey(), + publicId: t.varchar({ length: 32 }).notNull().unique(), ipAddress: t.varchar({ length: 40 }).notNull(), userAgent: t.text().notNull(), lastSeen: t.timestamp().notNull().defaultNow(),