From be604b24c6c3d25aecf6731a7a3557325bdd1c30 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Fri, 9 Sep 2022 20:12:22 +0300 Subject: [PATCH] audit logging --- package-lock.json | 254 ++++++++++++++++++ package.json | 4 + src/fe/scss/_logins.scss | 50 ++++ src/fe/scss/index.scss | 1 + src/fe/ts/index.ts | 5 + src/modules/objects/audit/audit.entity.ts | 36 +++ src/modules/objects/audit/audit.enum.ts | 10 + src/modules/objects/audit/audit.module.ts | 12 + src/modules/objects/audit/audit.providers.ts | 11 + src/modules/objects/audit/audit.service.ts | 146 ++++++++++ src/modules/objects/objects.module.ts | 3 + .../login/login.controller.ts | 6 + .../static-front-end/login/login.module.ts | 3 +- .../register/register.controller.ts | 6 +- .../register/register.module.ts | 3 +- .../settings/settings.controller.ts | 20 ++ .../settings/settings.module.ts | 2 + .../two-factor/two-factor.controller.ts | 5 + .../two-factor/two-factor.module.ts | 3 +- .../utility/services/form-utility.service.ts | 4 + views/login-list.pug | 45 ++++ 21 files changed, 625 insertions(+), 4 deletions(-) create mode 100644 src/fe/scss/_logins.scss create mode 100644 src/modules/objects/audit/audit.entity.ts create mode 100644 src/modules/objects/audit/audit.enum.ts create mode 100644 src/modules/objects/audit/audit.module.ts create mode 100644 src/modules/objects/audit/audit.providers.ts create mode 100644 src/modules/objects/audit/audit.service.ts create mode 100644 views/login-list.pug diff --git a/package-lock.json b/package-lock.json index 44bd770..9c3e4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "csrf": "^3.1.0", "dotenv": "^16.0.1", "express-session": "^1.17.3", + "express-useragent": "^1.0.15", + "geoip-lite": "^1.4.6", "image-size": "^1.0.2", "jsonwebtoken": "^8.5.1", "marked": "^4.0.18", @@ -54,6 +56,8 @@ "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.5", + "@types/express-useragent": "^1.0.2", + "@types/geoip-lite": "^1.4.1", "@types/jest": "28.1.7", "@types/jsonwebtoken": "^8.5.8", "@types/marked": "^4.0.4", @@ -3450,6 +3454,21 @@ "@types/express": "*" } }, + "node_modules/@types/express-useragent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/express-useragent/-/express-useragent-1.0.2.tgz", + "integrity": "sha512-eUVCqMsmEO7adMJSxuAARPUxbEJLYQJATiB86bx3MGeyUOTgKNnLTfAMaF+z84DftcH6NBbFFwiRomIcsFVdUQ==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/geoip-lite": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@types/geoip-lite/-/geoip-lite-1.4.1.tgz", + "integrity": "sha512-qHH5eF3rL1wwqpzdsgMdgskfdWXxxQvJb9POJ66NK7/1l3QXsqHLpIheh9OmhtqZ2CF7AmN0sA2R4PgW8JSm7w==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -4315,6 +4334,14 @@ "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4783,6 +4810,14 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -6347,6 +6382,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "node_modules/express-useragent": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/express-useragent/-/express-useragent-1.0.15.tgz", + "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==", + "engines": { + "node": ">=4.5" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6448,6 +6491,14 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6834,6 +6885,49 @@ "node": ">=6.9.0" } }, + "node_modules/geoip-lite": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.6.tgz", + "integrity": "sha512-JiG2zqGhFPJU/Zz//XkSfUJAaCWEz8rBi3k7RbNDEYkxGSkmguGNirJ1Q5C2ADKTMY7RqDRdxIbiX55zzZ5eJw==", + "dependencies": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "engines": { + "node": ">=5.10.0" + } + }, + "node_modules/geoip-lite/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/geoip-lite/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7270,6 +7364,24 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "dependencies": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8511,6 +8623,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -8658,6 +8775,14 @@ "node": ">= 8" } }, + "node_modules/lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9637,6 +9762,11 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -12518,6 +12648,15 @@ "node": ">=10" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -15026,6 +15165,21 @@ "@types/express": "*" } }, + "@types/express-useragent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/express-useragent/-/express-useragent-1.0.2.tgz", + "integrity": "sha512-eUVCqMsmEO7adMJSxuAARPUxbEJLYQJATiB86bx3MGeyUOTgKNnLTfAMaF+z84DftcH6NBbFFwiRomIcsFVdUQ==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/geoip-lite": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@types/geoip-lite/-/geoip-lite-1.4.1.tgz", + "integrity": "sha512-qHH5eF3rL1wwqpzdsgMdgskfdWXxxQvJb9POJ66NK7/1l3QXsqHLpIheh9OmhtqZ2CF7AmN0sA2R4PgW8JSm7w==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -15720,6 +15874,14 @@ "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" }, + "async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "requires": { + "lodash": "^4.17.14" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -16069,6 +16231,11 @@ "ieee754": "^1.1.13" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -17260,6 +17427,11 @@ } } }, + "express-useragent": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/express-useragent/-/express-useragent-1.0.15.tgz", + "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==" + }, "external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -17337,6 +17509,14 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "requires": { + "pend": "~1.2.0" + } + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -17628,6 +17808,39 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true }, + "geoip-lite": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.6.tgz", + "integrity": "sha512-JiG2zqGhFPJU/Zz//XkSfUJAaCWEz8rBi3k7RbNDEYkxGSkmguGNirJ1Q5C2ADKTMY7RqDRdxIbiX55zzZ5eJw==", + "requires": { + "async": "2.1 - 2.6.4", + "chalk": "4.1 - 4.1.2", + "iconv-lite": "0.4.13 - 0.6.3", + "ip-address": "5.8.9 - 5.9.4", + "lazy": "1.0.11", + "rimraf": "2.5.2 - 2.7.1", + "yauzl": "2.9.2 - 2.10.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -17933,6 +18146,23 @@ "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true }, + "ip-address": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz", + "integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==", + "requires": { + "jsbn": "1.1.0", + "lodash": "^4.17.15", + "sprintf-js": "1.1.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" + } + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -18870,6 +19100,11 @@ "argparse": "^2.0.1" } }, + "jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -18989,6 +19224,11 @@ "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", "dev": true }, + "lazy": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz", + "integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==" + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -19737,6 +19977,11 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -21762,6 +22007,15 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 761c3af..0c78eb1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "csrf": "^3.1.0", "dotenv": "^16.0.1", "express-session": "^1.17.3", + "express-useragent": "^1.0.15", + "geoip-lite": "^1.4.6", "image-size": "^1.0.2", "jsonwebtoken": "^8.5.1", "marked": "^4.0.18", @@ -69,6 +71,8 @@ "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.13", "@types/express-session": "^1.17.5", + "@types/express-useragent": "^1.0.2", + "@types/geoip-lite": "^1.4.1", "@types/jest": "28.1.7", "@types/jsonwebtoken": "^8.5.8", "@types/marked": "^4.0.4", diff --git a/src/fe/scss/_logins.scss b/src/fe/scss/_logins.scss new file mode 100644 index 0000000..ef92972 --- /dev/null +++ b/src/fe/scss/_logins.scss @@ -0,0 +1,50 @@ +.login { + display: flex; + flex-direction: column; + position: relative; + gap: 0.25rem; + padding: 8px; + + &__list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + &__title { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + &__current { + font-weight: bold; + } + + &__indicator { + position: absolute; + left: -16px; + width: 16px; + height: 16px; + border-radius: 100%; + + &--current { + background-color: rgb(0, 190, 63); + } + + &--other { + background-color: rgb(0, 136, 255); + } + } + + &__using { + display: flex; + flex-direction: row; + flex-wrap: wrap; + font-size: 0.875rem; + } + + p { + margin: 0; + } +} diff --git a/src/fe/scss/index.scss b/src/fe/scss/index.scss index 638ab9e..9e068a0 100644 --- a/src/fe/scss/index.scss +++ b/src/fe/scss/index.scss @@ -8,6 +8,7 @@ @import 'authorize'; @import 'settings'; @import 'modal'; +@import 'logins'; *, *::before, diff --git a/src/fe/ts/index.ts b/src/fe/ts/index.ts index 029fa46..0f5236f 100644 --- a/src/fe/ts/index.ts +++ b/src/fe/ts/index.ts @@ -18,4 +18,9 @@ import { ModalManager } from './modal/modals'; const modals = new ModalManager(); const avatar = new AvatarModal(); modals.register(avatar); + + const dateify = document.querySelectorAll('[data-locale-time]'); + dateify.forEach((element: HTMLElement) => { + element.innerText = new Date(element.innerText).toString(); + }); })(); diff --git a/src/modules/objects/audit/audit.entity.ts b/src/modules/objects/audit/audit.entity.ts new file mode 100644 index 0000000..94b58c7 --- /dev/null +++ b/src/modules/objects/audit/audit.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../user/user.entity'; + +@Entity() +export class AuditLog { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'text', nullable: false }) + action: string; + + @Column({ type: 'text', nullable: true }) + content: string; + + @Column({ type: 'text', nullable: true }) + actor_ip: string; + + @Column({ type: 'text', nullable: true }) + actor_ua: string; + + /** Potentially unwanted behavior will be flagged by the system */ + @Column({ default: false }) + flagged: boolean; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + public actor: User; + + @CreateDateColumn() + public created_at: Date; +} diff --git a/src/modules/objects/audit/audit.enum.ts b/src/modules/objects/audit/audit.enum.ts new file mode 100644 index 0000000..bcf8db7 --- /dev/null +++ b/src/modules/objects/audit/audit.enum.ts @@ -0,0 +1,10 @@ +export enum AuditAction { + LOGIN = 'login', + REGISTRATION = 'registration', + TOTP_ACTIVATE = 'totp_activate', + TOTP_DEACTIVATE = 'totp_deactivate', + PASSWORD_CHANGE = 'password_change', + EMAIL_CHANGE = 'email_change', + MALICIOUS_REQUEST = 'malicious_request', + THROTTLE = 'throttle', +} diff --git a/src/modules/objects/audit/audit.module.ts b/src/modules/objects/audit/audit.module.ts new file mode 100644 index 0000000..2a4f509 --- /dev/null +++ b/src/modules/objects/audit/audit.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigurationModule } from 'src/modules/config/config.module'; +import { DatabaseModule } from '../database/database.module'; +import { auditProviders } from './audit.providers'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [DatabaseModule, ConfigurationModule], + exports: [AuditService], + providers: [...auditProviders, AuditService], +}) +export class AuditModule {} diff --git a/src/modules/objects/audit/audit.providers.ts b/src/modules/objects/audit/audit.providers.ts new file mode 100644 index 0000000..4302298 --- /dev/null +++ b/src/modules/objects/audit/audit.providers.ts @@ -0,0 +1,11 @@ +import { FactoryProvider } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { AuditLog } from './audit.entity'; + +export const auditProviders: FactoryProvider>[] = [ + { + provide: 'AUDIT_REPOSITORY', + useFactory: (dataSource: DataSource) => dataSource.getRepository(AuditLog), + inject: ['DATA_SOURCE'], + }, +]; diff --git a/src/modules/objects/audit/audit.service.ts b/src/modules/objects/audit/audit.service.ts new file mode 100644 index 0000000..970f64b --- /dev/null +++ b/src/modules/objects/audit/audit.service.ts @@ -0,0 +1,146 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Request } from 'express'; +import { Repository } from 'typeorm'; +import { User } from '../user/user.entity'; +import { AuditLog } from './audit.entity'; +import { AuditAction } from './audit.enum'; +import { lookup, Lookup } from 'geoip-lite'; +import { parse, Details } from 'express-useragent'; +import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; + +export interface UserLoginEntry { + login_at: Date; + current: boolean; + location: Partial; + user_agent: Partial
; +} + +@Injectable() +export class AuditService { + constructor( + @Inject('AUDIT_REPOSITORY') + private readonly audit: Repository, + private readonly form: FormUtilityService, + ) {} + + public async insertAudit( + action: AuditAction, + comment?: string, + user?: User, + ip?: string, + ua?: string, + ) { + const audit = new AuditLog(); + audit.action = action as string; + audit.content = comment; + audit.actor_ip = ip; + audit.actor_ua = ua; + audit.actor = user; + + if ( + action === AuditAction.MALICIOUS_REQUEST || + action === AuditAction.THROTTLE + ) { + audit.flagged = true; + // TODO: email administrator + } + + await this.updateAudit(audit); + return audit; + } + + public async auditRequest( + req: Request, + type: AuditAction, + comment?: string, + user?: User, + ) { + return this.insertAudit( + type, + comment, + user || req.user || null, + req.ip, + req.header('user-agent'), + ); + } + + public getIPLocation(ip: string) { + return lookup(ip); + } + + public getUserAgentInfo(ua: string) { + return parse(ua); + } + + public async getUserLogins( + user: User, + sessid?: string, + ): Promise { + const userLogins: UserLoginEntry[] = []; + const auditEntries = await this.audit.find({ + where: { actor: { id: user.id }, action: AuditAction.LOGIN }, + order: { created_at: 'DESC' }, + take: 10, + }); + + auditEntries.forEach((entry) => { + userLogins.push({ + login_at: entry.created_at, + current: sessid === entry.content, + location: entry.actor_ip + ? this.form.pluckObject(this.getIPLocation(entry.actor_ip), [ + 'country', + 'city', + 'timezone', + 'll', + ]) + : null, + user_agent: entry.actor_ua + ? this.form.pluckObject(this.getUserAgentInfo(entry.actor_ua), [ + 'browser', + 'version', + 'os', + 'platform', + ]) + : null, + }); + }); + + return userLogins; + } + + public async getUserAccountCreation(user: User) { + const auditEntry = await this.audit.findOne({ + where: { actor: { id: user.id }, action: AuditAction.REGISTRATION }, + }); + + if (!auditEntry) { + return null; + } + + return { + created_at: auditEntry.created_at, + ip: auditEntry.actor_ip, + location: auditEntry.actor_ip + ? this.form.pluckObject(this.getIPLocation(auditEntry.actor_ip), [ + 'country', + 'city', + 'timezone', + 'll', + ]) + : null, + user_agent: auditEntry.actor_ua + ? this.form.pluckObject(this.getUserAgentInfo(auditEntry.actor_ua), [ + 'browser', + 'version', + 'os', + 'platform', + ]) + : null, + }; + } + + public async updateAudit(audit: AuditLog): Promise { + await this.audit.save(audit); + } +} diff --git a/src/modules/objects/objects.module.ts b/src/modules/objects/objects.module.ts index 8ce78aa..faa7a98 100644 --- a/src/modules/objects/objects.module.ts +++ b/src/modules/objects/objects.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigurationModule } from '../config/config.module'; +import { AuditModule } from './audit/audit.module'; import { DatabaseModule } from './database/database.module'; import { DocumentModule } from './document/document.module'; import { EmailModule } from './email/email.module'; @@ -21,6 +22,7 @@ import { UserModule } from './user/user.module'; UploadModule, UserModule, DocumentModule, + AuditModule, ], exports: [ DatabaseModule, @@ -31,6 +33,7 @@ import { UserModule } from './user/user.module'; UploadModule, UserModule, DocumentModule, + AuditModule, ], }) export class ObjectsModule {} diff --git a/src/modules/static-front-end/login/login.controller.ts b/src/modules/static-front-end/login/login.controller.ts index d966d92..4673daa 100644 --- a/src/modules/static-front-end/login/login.controller.ts +++ b/src/modules/static-front-end/login/login.controller.ts @@ -12,6 +12,8 @@ import { import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { SessionData } from 'express-session'; +import { AuditAction } from 'src/modules/objects/audit/audit.enum'; +import { AuditService } from 'src/modules/objects/audit/audit.service'; import { UserToken, UserTokenType, @@ -31,6 +33,7 @@ export class LoginController { private readonly userTokenService: UserTokenService, private readonly formUtil: FormUtilityService, private readonly token: TokenService, + private readonly audit: AuditService, ) {} @Get() @@ -74,6 +77,8 @@ export class LoginController { return; } + await this.audit.auditRequest(req, AuditAction.LOGIN, req.session.id, user); + if (await this.totpService.userHasTOTP(user)) { const challenge = { type: 'verify', user: user.uuid, remember }; const encrypted = await this.token.encryptChallenge(challenge); @@ -336,6 +341,7 @@ export class LoginController { await this.userService.updateUser(token.user); await this.userTokenService.delete(token); + await this.audit.auditRequest(req, AuditAction.PASSWORD_CHANGE, 'token'); req.flash('message', { error: false, diff --git a/src/modules/static-front-end/login/login.module.ts b/src/modules/static-front-end/login/login.module.ts index bf55ddb..c6adab5 100644 --- a/src/modules/static-front-end/login/login.module.ts +++ b/src/modules/static-front-end/login/login.module.ts @@ -8,12 +8,13 @@ import { CSRFMiddleware } from 'src/middleware/csrf.middleware'; import { FlashMiddleware } from 'src/middleware/flash.middleware'; import { UserMiddleware } from 'src/middleware/user.middleware'; import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware'; +import { AuditModule } from 'src/modules/objects/audit/audit.module'; import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module'; import { UserModule } from 'src/modules/objects/user/user.module'; import { LoginController } from './login.controller'; @Module({ - imports: [UserModule, UserTokenModule], + imports: [UserModule, UserTokenModule, AuditModule], controllers: [LoginController], }) export class LoginModule implements NestModule { diff --git a/src/modules/static-front-end/register/register.controller.ts b/src/modules/static-front-end/register/register.controller.ts index 31170ad..3a9fb24 100644 --- a/src/modules/static-front-end/register/register.controller.ts +++ b/src/modules/static-front-end/register/register.controller.ts @@ -12,6 +12,8 @@ import { import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { ConfigurationService } from 'src/modules/config/config.service'; +import { AuditAction } from 'src/modules/objects/audit/audit.enum'; +import { AuditService } from 'src/modules/objects/audit/audit.service'; import { UserService } from 'src/modules/objects/user/user.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; import { RegisterDto } from './register.interfaces'; @@ -22,6 +24,7 @@ export class RegisterController { private readonly userService: UserService, private readonly formUtil: FormUtilityService, private readonly config: ConfigurationService, + private readonly audit: AuditService, ) {} @Get() @@ -87,7 +90,8 @@ export class RegisterController { throw new Error('The passwords do not match!'); } - await this.userService.userRegistration(body, redirectTo); + const user = await this.userService.userRegistration(body, redirectTo); + await this.audit.auditRequest(req, AuditAction.REGISTRATION, null, user); req.flash('message', { error: false, diff --git a/src/modules/static-front-end/register/register.module.ts b/src/modules/static-front-end/register/register.module.ts index 3f5f553..77f6edb 100644 --- a/src/modules/static-front-end/register/register.module.ts +++ b/src/modules/static-front-end/register/register.module.ts @@ -8,11 +8,12 @@ import { CSRFMiddleware } from 'src/middleware/csrf.middleware'; import { FlashMiddleware } from 'src/middleware/flash.middleware'; import { UserMiddleware } from 'src/middleware/user.middleware'; import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware'; +import { AuditModule } from 'src/modules/objects/audit/audit.module'; import { UserModule } from 'src/modules/objects/user/user.module'; import { RegisterController } from './register.controller'; @Module({ - imports: [UserModule], + imports: [UserModule, AuditModule], controllers: [RegisterController], }) export class RegisterModule implements NestModule { diff --git a/src/modules/static-front-end/settings/settings.controller.ts b/src/modules/static-front-end/settings/settings.controller.ts index 0a2974c..8cec108 100644 --- a/src/modules/static-front-end/settings/settings.controller.ts +++ b/src/modules/static-front-end/settings/settings.controller.ts @@ -18,6 +18,8 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { unlink } from 'fs/promises'; +import { AuditAction } from 'src/modules/objects/audit/audit.enum'; +import { AuditService } from 'src/modules/objects/audit/audit.service'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { UploadService } from 'src/modules/objects/upload/upload.service'; @@ -38,6 +40,7 @@ export class SettingsController { private readonly _totp: UserTOTPService, private readonly _client: OAuth2ClientService, private readonly _oaToken: OAuth2TokenService, + private readonly _audit: AuditService, ) {} @Get() @@ -231,6 +234,11 @@ export class SettingsController { const newPassword = await this._user.hashPassword(new_password); req.user.password = newPassword; await this._user.updateUser(req.user); + await this._audit.auditRequest( + req, + AuditAction.PASSWORD_CHANGE, + 'settings', + ); req.flash('message', { error: false, @@ -291,6 +299,7 @@ export class SettingsController { req.user.email = email; await this._user.updateUser(req.user); + await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings'); req.flash('message', { error: false, @@ -311,4 +320,15 @@ export class SettingsController { req.session.destroy(() => res.redirect('/login')); } + + @Get('logins') + @Render('login-list') + public async userLogins(@Req() req: Request) { + const logins = await this._audit.getUserLogins(req.user, req.session.id); + const creation = await this._audit.getUserAccountCreation(req.user); + return this._form.populateTemplate(req, { + logins, + creation, + }); + } } diff --git a/src/modules/static-front-end/settings/settings.module.ts b/src/modules/static-front-end/settings/settings.module.ts index b40a2f3..7210db7 100644 --- a/src/modules/static-front-end/settings/settings.module.ts +++ b/src/modules/static-front-end/settings/settings.module.ts @@ -23,6 +23,7 @@ import { SettingsController } from './settings.controller'; import { SettingsService } from './settings.service'; import { CSRFMiddleware } from 'src/middleware/csrf.middleware'; import { UserMiddleware } from 'src/middleware/user.middleware'; +import { AuditModule } from 'src/modules/objects/audit/audit.module'; @Module({ controllers: [SettingsController], @@ -31,6 +32,7 @@ import { UserMiddleware } from 'src/middleware/user.middleware'; UploadModule, UserModule, UserTokenModule, + AuditModule, OAuth2Module, OAuth2ClientModule, OAuth2TokenModule, diff --git a/src/modules/static-front-end/two-factor/two-factor.controller.ts b/src/modules/static-front-end/two-factor/two-factor.controller.ts index 614ad49..f03ba1e 100644 --- a/src/modules/static-front-end/two-factor/two-factor.controller.ts +++ b/src/modules/static-front-end/two-factor/two-factor.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common'; import { Request, Response } from 'express'; +import { AuditAction } from 'src/modules/objects/audit/audit.enum'; +import { AuditService } from 'src/modules/objects/audit/audit.service'; import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service'; import { UserService } from 'src/modules/objects/user/user.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; @@ -14,6 +16,7 @@ export class TwoFactorController { private token: TokenService, private user: UserService, private form: FormUtilityService, + private audit: AuditService, ) {} @Get('activate') @@ -92,6 +95,7 @@ export class TwoFactorController { // TODO: show the recovery tokens to the user await this.totp.activateTOTP(req.user, secret); + await this.audit.auditRequest(req, AuditAction.TOTP_ACTIVATE); req.flash('message', { error: false, text: 'Two-factor authenticator has been enabled successfully. Your account is now more secure!', @@ -132,6 +136,7 @@ export class TwoFactorController { } await this.totp.deactivateTOTP(twoFA); + await this.audit.auditRequest(req, AuditAction.TOTP_DEACTIVATE); } catch (e: any) { req.flash('message', { error: true, diff --git a/src/modules/static-front-end/two-factor/two-factor.module.ts b/src/modules/static-front-end/two-factor/two-factor.module.ts index 52f0cf9..9191a00 100644 --- a/src/modules/static-front-end/two-factor/two-factor.module.ts +++ b/src/modules/static-front-end/two-factor/two-factor.module.ts @@ -3,12 +3,13 @@ import { AuthMiddleware } from 'src/middleware/auth.middleware'; import { CSRFMiddleware } from 'src/middleware/csrf.middleware'; import { FlashMiddleware } from 'src/middleware/flash.middleware'; import { UserMiddleware } from 'src/middleware/user.middleware'; +import { AuditModule } from 'src/modules/objects/audit/audit.module'; import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module'; import { UserModule } from 'src/modules/objects/user/user.module'; import { TwoFactorController } from './two-factor.controller'; @Module({ - imports: [UserModule, UserTokenModule], + imports: [UserModule, UserTokenModule, AuditModule], controllers: [TwoFactorController], }) export class TwoFactorModule implements NestModule { diff --git a/src/modules/utility/services/form-utility.service.ts b/src/modules/utility/services/form-utility.service.ts index 640fdd9..bd76cc5 100644 --- a/src/modules/utility/services/form-utility.service.ts +++ b/src/modules/utility/services/form-utility.service.ts @@ -40,6 +40,7 @@ export class FormUtilityService { * @returns Stripped object */ public stripObject(object: T, keys: string[]): T { + if (!object) return null; return keys.reduce((obj, field) => { delete obj[field]; return obj; @@ -53,6 +54,7 @@ export class FormUtilityService { * @returns Plucked object */ public pluckObject(object: T, keys: string[]): Partial { + if (!object) return null; return Object.keys(object).reduce>((obj, field) => { if (keys.includes(field)) { obj[field] = object[field]; @@ -68,6 +70,7 @@ export class FormUtilityService { * @returns Stripped object */ public stripObjectArray(array: T[], keys: string[]): T[] { + if (!array) return null; return array.map((object) => this.stripObject(object, keys)); } @@ -78,6 +81,7 @@ export class FormUtilityService { * @returns Plucked object */ public pluckObjectArray(array: T[], keys: string[]): Partial[] { + if (!array) return null; return array.map((object) => this.pluckObject(object, keys)); } diff --git a/views/login-list.pug b/views/login-list.pug new file mode 100644 index 0000000..b410596 --- /dev/null +++ b/views/login-list.pug @@ -0,0 +1,45 @@ +extends partials/layout.pug + +block title + |Login history | Icy Network + +block body + include partials/logo.pug + div.container + div.center-box + h1 Login history + + div.login__list + each login in logins + div.login + if login.current + div.login__indicator.login__indicator--current + else + div.login__indicator.login__indicator--other + div.login__title + p Login at + span(data-locale-time) #{login.login_at.toISOString()} + if login.current + span.login__current (current) + div.login__using + if login.location + span.login__location near #{login.location.city}, #{login.location.country} + |  + if login.user_agent + span.login__browser using #{login.user_agent.browser} #{login.user_agent.version} on #{login.user_agent.platform} (#{login.user_agent.os}) + if creation + div.login + div.login__indicator.login__indicator--current + div.login__title + p Account created at + span(data-locale-time) #{creation.created_at.toISOString()} + div.login__using + if creation.location + span.login__location near #{creation.location.city}, #{creation.location.country} + |  + if creation.user_agent + span.login__browser using #{creation.user_agent.browser} #{creation.user_agent.version} on #{creation.user_agent.platform} (#{creation.user_agent.os}) + |  + if creation.ip + span.login__ip + |IP address: #{creation.ip}