diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1e0296a --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME= +DB_PASSWORD= +DB_NAME= + +# SMS +SMS_API_KEY= +SMS_SENDER= +SMS_API_BASE_URL=https://api.sms-webservice.com/api/V3 + +# OTP +OTP_LENGTH=5 +OTP_EXPIRES_IN_MINUTES=5 +OTP_MAX_ATTEMPTS=5 +OTP_COOLDOWN_SECONDS=60 +OTP_DEBUG=false + +# JWT +JWT_ACCESS_SECRET= +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_SECRET= +JWT_REFRESH_EXPIRES_IN=7d + +# App +PORT=3000 +NODE_ENV=development diff --git a/package-lock.json b/package-lock.json index d601685..772da75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,20 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.2", + "axios": "^1.18.1", + "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -27,9 +34,11 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -2081,6 +2090,17 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nestjs/axios": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", + "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "axios": "^1.3.1", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.23", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.23.tgz", @@ -2358,6 +2378,29 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.27", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", @@ -2773,6 +2816,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2907,6 +2960,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2914,16 +2977,53 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -3882,6 +3982,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4077,9 +4189,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -4220,6 +4343,20 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4362,6 +4499,12 @@ "ieee754": "^1.1.13" } }, + "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", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4695,7 +4838,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4951,7 +5093,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5057,6 +5198,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5164,7 +5314,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5708,6 +5857,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5757,7 +5926,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5774,7 +5942,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -5784,7 +5951,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6140,7 +6306,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6191,6 +6356,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7365,6 +7543,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7467,6 +7688,42 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7481,6 +7738,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7754,6 +8017,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -7764,6 +8036,17 @@ "lodash": "^4.17.21" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7994,6 +8277,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8071,6 +8390,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -8388,6 +8712,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8652,7 +8985,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9992,7 +10324,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -10099,6 +10430,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 18ecf50..e7fc976 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,20 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.2", + "axios": "^1.18.1", + "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -38,9 +45,11 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 18caba8..e9a58d0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,13 +1,18 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from './auth/auth.module'; import { CustomersModule } from './customers/customers.module'; +import configuration from './config/configuration'; import { SalonsModule } from './salons/salons.module'; import { UsersModule } from './users/users.module'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DB_HOST, @@ -17,7 +22,9 @@ import { UsersModule } from './users/users.module'; database: process.env.DB_NAME, autoLoadEntities: true, entities: [__dirname + '/**/*.entity.{js,ts}'], + synchronize: process.env.NODE_ENV !== 'production', }), + AuthModule, UsersModule, SalonsModule, CustomersModule, diff --git a/src/auth/auth-guards.module.ts b/src/auth/auth-guards.module.ts new file mode 100644 index 0000000..39c57ff --- /dev/null +++ b/src/auth/auth-guards.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PermissionsGuard } from './guards/permissions.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Module({ + providers: [RolesGuard, PermissionsGuard], + exports: [RolesGuard, PermissionsGuard], +}) +export class AuthGuardsModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..b84aff7 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { Public } from './decorators/public.decorator'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { RequestOtpDto } from './dto/request-otp.dto'; +import { VerifyOtpDto } from './dto/verify-otp.dto'; +import { AuthUser } from './interfaces/auth-user.interface'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('otp/request') + requestOtp(@Body() dto: RequestOtpDto) { + return this.authService.requestOtp(dto.mobile); + } + + @Public() + @Post('otp/verify') + verifyOtp(@Body() dto: VerifyOtpDto) { + return this.authService.verifyOtp(dto); + } + + @Public() + @Post('refresh') + refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refresh(dto.refreshToken); + } + + @Post('logout') + logout(@CurrentUser() user: AuthUser, @Body() dto: RefreshTokenDto) { + return this.authService.logout(user.id, dto.refreshToken); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..dde5581 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { Customer } from '../customers/entities/customer.entity'; +import { SmsModule } from '../sms/sms.module'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { Otp } from './entities/otp.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Otp, RefreshToken, Customer]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.getOrThrow('JWT_ACCESS_SECRET'), + signOptions: { + expiresIn: (configService.get('JWT_ACCESS_EXPIRES_IN') ?? + '15m') as `${number}m`, + }, + }), + }), + UsersModule, + SmsModule, + AuthorizationModule, + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..119eb52 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,296 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as bcrypt from 'bcrypt'; +import { Repository, IsNull } from 'typeorm'; +import { Customer } from '../customers/entities/customer.entity'; +import { SmsService } from '../sms/sms.service'; +import { UserRole } from '../users/enums/user-role.enum'; +import { User } from '../users/entities/user.entity'; +import { UsersService } from '../users/users.service'; +import { VerifyOtpDto } from './dto/verify-otp.dto'; +import { Otp } from './entities/otp.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; + +export interface AuthTokensResponse { + accessToken: string; + refreshToken: string; + user: { + id: string; + firstName: string; + lastName: string; + mobile: string; + role: UserRole; + }; +} + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly otpLength: number; + private readonly otpExpiresInMinutes: number; + private readonly otpMaxAttempts: number; + private readonly otpCooldownSeconds: number; + private readonly otpDebug: boolean; + private readonly refreshExpiresIn: string; + + constructor( + @InjectRepository(Otp) + private readonly otpRepository: Repository, + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(Customer) + private readonly customersRepository: Repository, + private readonly usersService: UsersService, + private readonly smsService: SmsService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) { + this.otpLength = parseInt( + this.configService.get('OTP_LENGTH') ?? '5', + 10, + ); + this.otpExpiresInMinutes = parseInt( + this.configService.get('OTP_EXPIRES_IN_MINUTES') ?? '5', + 10, + ); + this.otpMaxAttempts = parseInt( + this.configService.get('OTP_MAX_ATTEMPTS') ?? '5', + 10, + ); + this.otpCooldownSeconds = parseInt( + this.configService.get('OTP_COOLDOWN_SECONDS') ?? '60', + 10, + ); + this.otpDebug = this.configService.get('OTP_DEBUG') === 'true'; + this.refreshExpiresIn = + this.configService.get('JWT_REFRESH_EXPIRES_IN') ?? '7d'; + } + + async requestOtp(mobile: string): Promise<{ message: string }> { + const recentOtp = await this.otpRepository.findOne({ + where: { mobile, verified: false }, + order: { createdAt: 'DESC' }, + }); + + if (recentOtp) { + const cooldownEnd = + recentOtp.createdAt.getTime() + this.otpCooldownSeconds * 1000; + if (Date.now() < cooldownEnd) { + throw new HttpException( + 'Please wait before requesting a new OTP', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + await this.otpRepository.delete({ mobile, verified: false }); + + const code = this.generateOtpCode(); + const hashedCode = await bcrypt.hash(code, 10); + const expiresAt = new Date( + Date.now() + this.otpExpiresInMinutes * 60 * 1000, + ); + + await this.otpRepository.save({ + mobile, + code: hashedCode, + expiresAt, + }); + + const sent = await this.smsService.sendSms(mobile, `کد ورود: ${code}`); + + if (!sent) { + throw new BadRequestException('Failed to send OTP'); + } + + if ( + this.otpDebug && + this.configService.get('NODE_ENV') === 'development' + ) { + this.logger.debug(`OTP for ${mobile}: ${code}`); + } + + return { message: 'OTP sent' }; + } + + async verifyOtp(dto: VerifyOtpDto): Promise { + const otp = await this.otpRepository.findOne({ + where: { mobile: dto.mobile, verified: false }, + order: { createdAt: 'DESC' }, + }); + + if (!otp) { + throw new UnauthorizedException('Invalid or expired OTP'); + } + + if (otp.expiresAt < new Date()) { + throw new UnauthorizedException('Invalid or expired OTP'); + } + + if (otp.attempts >= this.otpMaxAttempts) { + throw new UnauthorizedException('Too many failed attempts'); + } + + const isValid = await bcrypt.compare(dto.code, otp.code); + if (!isValid) { + otp.attempts += 1; + await this.otpRepository.save(otp); + throw new UnauthorizedException('Invalid or expired OTP'); + } + + otp.verified = true; + await this.otpRepository.save(otp); + + const user = await this.resolveUserForLogin(dto); + return this.issueTokens(user); + } + + async refresh(refreshToken: string): Promise { + const storedTokens = await this.refreshTokenRepository.find({ + where: { revokedAt: IsNull() }, + relations: { user: true }, + }); + + let matchedToken: RefreshToken | null = null; + for (const stored of storedTokens) { + if (await bcrypt.compare(refreshToken, stored.tokenHash)) { + if (stored.expiresAt < new Date()) { + throw new UnauthorizedException('Invalid refresh token'); + } + matchedToken = stored; + break; + } + } + + if (!matchedToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + matchedToken.revokedAt = new Date(); + await this.refreshTokenRepository.save(matchedToken); + + return this.issueTokens(matchedToken.user); + } + + async logout(userId: string, refreshToken: string): Promise { + const storedTokens = await this.refreshTokenRepository.find({ + where: { userId, revokedAt: IsNull() }, + }); + + for (const stored of storedTokens) { + if (await bcrypt.compare(refreshToken, stored.tokenHash)) { + stored.revokedAt = new Date(); + await this.refreshTokenRepository.save(stored); + return; + } + } + + throw new UnauthorizedException('Invalid refresh token'); + } + + private async resolveUserForLogin(dto: VerifyOtpDto): Promise { + const existingUser = await this.usersService.findByMobile(dto.mobile); + + if (existingUser) { + return existingUser; + } + + return this.createCustomerUser(dto); + } + + private async createCustomerUser(dto: VerifyOtpDto): Promise { + const firstName = dto.firstName ?? 'کاربر'; + const lastName = dto.lastName ?? dto.mobile.slice(-4); + const dateOfBirth = dto.dateOfBirth ?? '2000-01-01'; + const address = dto.address ?? '-'; + + const user = await this.usersService.create({ + firstName, + lastName, + mobile: dto.mobile, + role: UserRole.CUSTOMER, + }); + + await this.customersRepository.save({ + userId: user.id, + dateOfBirth, + address, + }); + + return user; + } + + private async issueTokens(user: User): Promise { + const payload: JwtPayload = { sub: user.id, role: user.role }; + + const accessToken = this.jwtService.sign(payload); + const refreshToken = await this.createRefreshToken(user.id); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + mobile: user.mobile, + role: user.role, + }, + }; + } + + private async createRefreshToken(userId: string): Promise { + const refreshToken = this.jwtService.sign( + { sub: userId }, + { + secret: this.configService.getOrThrow('JWT_REFRESH_SECRET'), + expiresIn: this.refreshExpiresIn as `${number}d`, + }, + ); + + const tokenHash = await bcrypt.hash(refreshToken, 10); + const expiresAt = this.parseExpiresIn(this.refreshExpiresIn); + + await this.refreshTokenRepository.save({ + userId, + tokenHash, + expiresAt, + }); + + return refreshToken; + } + + private generateOtpCode(): string { + const max = Math.pow(10, this.otpLength); + const min = Math.pow(10, this.otpLength - 1); + return String(Math.floor(min + Math.random() * (max - min))); + } + + private parseExpiresIn(expiresIn: string): Date { + const match = expiresIn.match(/^(\d+)([dhms])$/); + if (!match) { + return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + }; + + return new Date(Date.now() + value * multipliers[unit]); + } +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..2044d57 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUser } from '../interfaces/auth-user.interface'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser => { + const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>(); + return request.user; + }, +); diff --git a/src/auth/decorators/optional-current-user.decorator.ts b/src/auth/decorators/optional-current-user.decorator.ts new file mode 100644 index 0000000..02db1be --- /dev/null +++ b/src/auth/decorators/optional-current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUser } from '../interfaces/auth-user.interface'; + +export const OptionalCurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser | undefined => { + const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>(); + return request.user; + }, +); diff --git a/src/auth/decorators/permissions.decorator.ts b/src/auth/decorators/permissions.decorator.ts new file mode 100644 index 0000000..fb384cf --- /dev/null +++ b/src/auth/decorators/permissions.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { PermissionCode } from '../../authorization/enums/permission-code.enum'; + +export const PERMISSIONS_KEY = 'permissions'; +export const Permissions = (...permissions: PermissionCode[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..39b08e9 --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../users/enums/user-role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/dto/refresh-token.dto.ts b/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..97cfabe --- /dev/null +++ b/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + @IsNotEmpty() + refreshToken: string; +} diff --git a/src/auth/dto/request-otp.dto.ts b/src/auth/dto/request-otp.dto.ts new file mode 100644 index 0000000..3446308 --- /dev/null +++ b/src/auth/dto/request-otp.dto.ts @@ -0,0 +1,6 @@ +import { Matches } from 'class-validator'; + +export class RequestOtpDto { + @Matches(/^09\d{9}$/) + mobile: string; +} diff --git a/src/auth/dto/verify-otp.dto.ts b/src/auth/dto/verify-otp.dto.ts new file mode 100644 index 0000000..2c87218 --- /dev/null +++ b/src/auth/dto/verify-otp.dto.ts @@ -0,0 +1,35 @@ +import { + IsDateString, + IsOptional, + IsString, + Length, + Matches, +} from 'class-validator'; + +export class VerifyOtpDto { + @Matches(/^09\d{9}$/) + mobile: string; + + @IsString() + @Length(4, 6) + code: string; + + @IsOptional() + @IsString() + @Length(1, 100) + firstName?: string; + + @IsOptional() + @IsString() + @Length(1, 100) + lastName?: string; + + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @IsOptional() + @IsString() + @Length(1, 500) + address?: string; +} diff --git a/src/auth/entities/otp.entity.ts b/src/auth/entities/otp.entity.ts new file mode 100644 index 0000000..defaf96 --- /dev/null +++ b/src/auth/entities/otp.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('otps') +export class Otp { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ length: 11 }) + mobile: string; + + @Column() + code: string; + + @Column({ type: 'timestamp' }) + expiresAt: Date; + + @Column({ default: 0 }) + attempts: number; + + @Column({ default: false }) + verified: boolean; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/auth/entities/refresh-token.entity.ts b/src/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..d7ab878 --- /dev/null +++ b/src/auth/entities/refresh-token.entity.ts @@ -0,0 +1,34 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + tokenHash: string; + + @Column({ type: 'timestamp' }) + expiresAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + revokedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..a6cd004 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,35 @@ +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: Error | null, user: T): T { + if (err || !user) { + throw err ?? new UnauthorizedException(); + } + return user; + } +} diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts new file mode 100644 index 0000000..97d46cc --- /dev/null +++ b/src/auth/guards/permissions.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionCode } from '../../authorization/enums/permission-code.enum'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; +import { AuthUser } from '../interfaces/auth-user.interface'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.getAllAndOverride< + PermissionCode[] + >(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]); + + if (!requiredPermissions?.length) { + return true; + } + + const { user } = context.switchToHttp().getRequest<{ user: AuthUser }>(); + + if (user.role !== UserRole.ADMIN) { + throw new ForbiddenException(); + } + + if (user.permissions.length === 0) { + return true; + } + + const hasAllPermissions = requiredPermissions.every((permission) => + user.permissions.includes(permission), + ); + + if (!hasAllPermissions) { + throw new ForbiddenException(); + } + + return true; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..7a8ac5f --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,34 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { AuthUser } from '../interfaces/auth-user.interface'; +import { UserRole } from '../../users/enums/user-role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles?.length) { + return true; + } + + const { user } = context.switchToHttp().getRequest<{ user: AuthUser }>(); + + if (!requiredRoles.includes(user.role)) { + throw new ForbiddenException(); + } + + return true; + } +} diff --git a/src/auth/interfaces/auth-user.interface.ts b/src/auth/interfaces/auth-user.interface.ts new file mode 100644 index 0000000..7fb7ff2 --- /dev/null +++ b/src/auth/interfaces/auth-user.interface.ts @@ -0,0 +1,8 @@ +import { UserRole } from '../../users/enums/user-role.enum'; + +export class AuthUser { + id: string; + role: UserRole; + mobile: string; + permissions: string[]; +} diff --git a/src/auth/interfaces/jwt-payload.interface.ts b/src/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..7f0d981 --- /dev/null +++ b/src/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,6 @@ +import { UserRole } from '../../users/enums/user-role.enum'; + +export interface JwtPayload { + sub: string; + role: UserRole; +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..abef868 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthorizationService } from '../../authorization/authorization.service'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { UsersService } from '../../users/users.service'; +import { AuthUser } from '../interfaces/auth-user.interface'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + configService: ConfigService, + private readonly usersService: UsersService, + private readonly authorizationService: AuthorizationService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow('JWT_ACCESS_SECRET'), + }); + } + + async validate(payload: JwtPayload): Promise { + const user = await this.usersService.findOne(payload.sub); + + const permissions = + user.role === UserRole.ADMIN + ? await this.authorizationService.loadPermissions(user.id) + : []; + + return { + id: user.id, + role: user.role, + mobile: user.mobile, + permissions, + }; + } +} diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts new file mode 100644 index 0000000..40afd5e --- /dev/null +++ b/src/authorization/authorization.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthorizationService } from './authorization.service'; +import { Permission } from './entities/permission.entity'; +import { UserPermission } from './entities/user-permission.entity'; +import { CustomerPolicy } from './policies/customer.policy'; +import { SalonPolicy } from './policies/salon.policy'; + +@Module({ + imports: [TypeOrmModule.forFeature([Permission, UserPermission])], + providers: [AuthorizationService, SalonPolicy, CustomerPolicy], + exports: [AuthorizationService, SalonPolicy, CustomerPolicy], +}) +export class AuthorizationModule {} diff --git a/src/authorization/authorization.service.ts b/src/authorization/authorization.service.ts new file mode 100644 index 0000000..49fa6e8 --- /dev/null +++ b/src/authorization/authorization.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserRole } from '../users/enums/user-role.enum'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserPermission } from './entities/user-permission.entity'; + +@Injectable() +export class AuthorizationService { + constructor( + @InjectRepository(UserPermission) + private readonly userPermissionsRepository: Repository, + ) {} + + async loadPermissions(userId: string): Promise { + const userPermissions = await this.userPermissionsRepository.find({ + where: { userId }, + relations: { permission: true }, + }); + + return userPermissions.map((up) => up.permission.code); + } + + hasPermission(user: AuthUser, code: string): boolean { + return user.permissions.includes(code); + } + + isAdmin(user: AuthUser): boolean { + return user.role === UserRole.ADMIN; + } +} diff --git a/src/authorization/entities/permission.entity.ts b/src/authorization/entities/permission.entity.ts new file mode 100644 index 0000000..7dfc01f --- /dev/null +++ b/src/authorization/entities/permission.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('permissions') +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + code: string; + + @Column() + description: string; +} diff --git a/src/authorization/entities/user-permission.entity.ts b/src/authorization/entities/user-permission.entity.ts new file mode 100644 index 0000000..a3b4bbc --- /dev/null +++ b/src/authorization/entities/user-permission.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Permission } from './permission.entity'; + +@Entity('user_permissions') +@Unique(['userId', 'permissionId']) +export class UserPermission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + permissionId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ManyToOne(() => Permission, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permissionId' }) + permission: Permission; +} diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts new file mode 100644 index 0000000..23a226e --- /dev/null +++ b/src/authorization/enums/permission-code.enum.ts @@ -0,0 +1,10 @@ +export enum PermissionCode { + USERS_READ = 'users:read', + USERS_WRITE = 'users:write', + USERS_DELETE = 'users:delete', + SALONS_READ = 'salons:read', + SALONS_WRITE = 'salons:write', + SALONS_DELETE = 'salons:delete', + CUSTOMERS_READ = 'customers:read', + CUSTOMERS_WRITE = 'customers:write', +} diff --git a/src/authorization/policies/customer.policy.ts b/src/authorization/policies/customer.policy.ts new file mode 100644 index 0000000..71d514b --- /dev/null +++ b/src/authorization/policies/customer.policy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { Customer } from '../../customers/entities/customer.entity'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class CustomerPolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canRead(user: AuthUser, customer: Customer): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.CUSTOMER) { + return customer.userId === user.id; + } + return false; + } + + canUpdate(user: AuthUser, customer: Customer): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.CUSTOMER) { + return customer.userId === user.id; + } + return false; + } +} diff --git a/src/authorization/policies/salon.policy.ts b/src/authorization/policies/salon.policy.ts new file mode 100644 index 0000000..65da917 --- /dev/null +++ b/src/authorization/policies/salon.policy.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { Salon } from '../../salons/entities/salon.entity'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class SalonPolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canRead(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return salon.ownerId === user.id; + } + if (user.role === UserRole.CUSTOMER) { + return true; + } + return false; + } + + canUpdate(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return salon.ownerId === user.id; + } + return false; + } + + canDelete(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return salon.ownerId === user.id; + } + return false; + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100644 index 0000000..ad4ce0e --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,24 @@ +export default () => { + const isDev = process.env.NODE_ENV !== 'production'; + + return { + JWT_ACCESS_SECRET: + process.env.JWT_ACCESS_SECRET ?? + (isDev ? 'dev-jwt-access-secret-change-me' : undefined), + JWT_REFRESH_SECRET: + process.env.JWT_REFRESH_SECRET ?? + (isDev ? 'dev-jwt-refresh-secret-change-me' : undefined), + JWT_ACCESS_EXPIRES_IN: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m', + JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d', + SMS_API_KEY: process.env.SMS_API_KEY ?? '', + SMS_SENDER: process.env.SMS_SENDER ?? '', + SMS_API_BASE_URL: + process.env.SMS_API_BASE_URL ?? + 'https://api.sms-webservice.com/api/V3', + OTP_LENGTH: process.env.OTP_LENGTH ?? '5', + OTP_EXPIRES_IN_MINUTES: process.env.OTP_EXPIRES_IN_MINUTES ?? '5', + OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5', + OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60', + OTP_DEBUG: process.env.OTP_DEBUG ?? 'false', + }; +}; diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts index e688d6c..a90168c 100644 --- a/src/customers/customers.controller.ts +++ b/src/customers/customers.controller.ts @@ -6,39 +6,49 @@ import { Param, ParseUUIDPipe, Patch, - Post, + UseGuards, } from '@nestjs/common'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from '../users/enums/user-role.enum'; import { CustomersService } from './customers.service'; -import { CreateCustomerDto } from './dto/create-customer.dto'; import { UpdateCustomerDto } from './dto/update-customer.dto'; @Controller('customers') +@UseGuards(RolesGuard, PermissionsGuard) export class CustomersController { constructor(private readonly customersService: CustomersService) {} - @Post() - create(@Body() createCustomerDto: CreateCustomerDto) { - return this.customersService.create(createCustomerDto); - } - + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.CUSTOMERS_READ) @Get() findAll() { return this.customersService.findAll(); } @Get(':id') - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.customersService.findOne(id); + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.customersService.findOne(id, user); } @Patch(':id') update( @Param('id', ParseUUIDPipe) id: string, @Body() updateCustomerDto: UpdateCustomerDto, + @CurrentUser() user: AuthUser, ) { - return this.customersService.update(id, updateCustomerDto); + return this.customersService.update(id, updateCustomerDto, user); } + @Roles(UserRole.ADMIN) @Delete(':id') remove(@Param('id', ParseUUIDPipe) id: string) { return this.customersService.remove(id); diff --git a/src/customers/customers.module.ts b/src/customers/customers.module.ts index 646b819..d39cc44 100644 --- a/src/customers/customers.module.ts +++ b/src/customers/customers.module.ts @@ -1,12 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; import { UsersModule } from '../users/users.module'; import { CustomersController } from './customers.controller'; import { CustomersService } from './customers.service'; import { Customer } from './entities/customer.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Customer]), UsersModule], + imports: [ + TypeOrmModule.forFeature([Customer]), + UsersModule, + AuthorizationModule, + AuthGuardsModule, + ], controllers: [CustomersController], providers: [CustomersService], exports: [CustomersService], diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 440d41c..8d0e32d 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -1,6 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { CustomerPolicy } from '../authorization/policies/customer.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { UserRole } from '../users/enums/user-role.enum'; import { UsersService } from '../users/users.service'; import { CreateCustomerDto } from './dto/create-customer.dto'; @@ -13,6 +19,7 @@ export class CustomersService { @InjectRepository(Customer) private readonly customersRepository: Repository, private readonly usersService: UsersService, + private readonly customerPolicy: CustomerPolicy, ) {} async create(createCustomerDto: CreateCustomerDto): Promise { @@ -37,7 +44,7 @@ export class CustomersService { return this.customersRepository.find({ relations: { user: true } }); } - async findOne(id: string): Promise { + async findOne(id: string, user?: AuthUser): Promise { const customer = await this.customersRepository.findOne({ where: { id }, relations: { user: true }, @@ -45,14 +52,24 @@ export class CustomersService { if (!customer) { throw new NotFoundException(`Customer #${id} not found`); } + + if (user && !this.customerPolicy.canRead(user, customer)) { + throw new ForbiddenException(); + } + return customer; } async update( id: string, updateCustomerDto: UpdateCustomerDto, + user: AuthUser, ): Promise { const customer = await this.findOne(id); + if (!this.customerPolicy.canUpdate(user, customer)) { + throw new ForbiddenException(); + } + const { firstName, lastName, mobile, dateOfBirth, address } = updateCustomerDto; @@ -76,7 +93,7 @@ export class CustomersService { } await this.customersRepository.save(customer); - return this.findOne(customer.id); + return this.findOne(customer.id, user); } async remove(id: string): Promise { diff --git a/src/salons/dto/create-salon.dto.ts b/src/salons/dto/create-salon.dto.ts index ac9baee..662f11b 100644 --- a/src/salons/dto/create-salon.dto.ts +++ b/src/salons/dto/create-salon.dto.ts @@ -25,6 +25,7 @@ export class CreateSalonDto { @Matches(/^09\d{9}$/) phone: string; + @IsOptional() @IsUUID() - ownerId: string; + ownerId?: string; } diff --git a/src/salons/salons.controller.ts b/src/salons/salons.controller.ts index 9f58ab0..5236fcf 100644 --- a/src/salons/salons.controller.ts +++ b/src/salons/salons.controller.ts @@ -7,40 +7,61 @@ import { ParseUUIDPipe, Patch, Post, + UseGuards, } from '@nestjs/common'; +import { OptionalCurrentUser } from '../auth/decorators/optional-current-user.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from '../users/enums/user-role.enum'; import { CreateSalonDto } from './dto/create-salon.dto'; import { UpdateSalonDto } from './dto/update-salon.dto'; import { SalonsService } from './salons.service'; @Controller('salons') +@UseGuards(RolesGuard) export class SalonsController { constructor(private readonly salonsService: SalonsService) {} + @Roles(UserRole.ADMIN, UserRole.SALON) @Post() - create(@Body() createSalonDto: CreateSalonDto) { - return this.salonsService.create(createSalonDto); + create( + @Body() createSalonDto: CreateSalonDto, + @CurrentUser() user: AuthUser, + ) { + return this.salonsService.create(createSalonDto, user); } + @Public() @Get() - findAll() { - return this.salonsService.findAll(); + findAll(@OptionalCurrentUser() user?: AuthUser) { + return this.salonsService.findAll(user); } + @Public() @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string) { return this.salonsService.findOne(id); } + @Roles(UserRole.ADMIN, UserRole.SALON) @Patch(':id') update( @Param('id', ParseUUIDPipe) id: string, @Body() updateSalonDto: UpdateSalonDto, + @CurrentUser() user: AuthUser, ) { - return this.salonsService.update(id, updateSalonDto); + return this.salonsService.update(id, updateSalonDto, user); } + @Roles(UserRole.ADMIN, UserRole.SALON) @Delete(':id') - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.salonsService.remove(id); + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonsService.remove(id, user); } } diff --git a/src/salons/salons.module.ts b/src/salons/salons.module.ts index ec15bbf..b115bc6 100644 --- a/src/salons/salons.module.ts +++ b/src/salons/salons.module.ts @@ -1,12 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; import { UsersModule } from '../users/users.module'; import { Salon } from './entities/salon.entity'; import { SalonsController } from './salons.controller'; import { SalonsService } from './salons.service'; @Module({ - imports: [TypeOrmModule.forFeature([Salon]), UsersModule], + imports: [ + TypeOrmModule.forFeature([Salon]), + UsersModule, + AuthorizationModule, + AuthGuardsModule, + ], controllers: [SalonsController], providers: [SalonsService], exports: [SalonsService], diff --git a/src/salons/salons.service.ts b/src/salons/salons.service.ts index c1ba22c..731583b 100644 --- a/src/salons/salons.service.ts +++ b/src/salons/salons.service.ts @@ -1,10 +1,13 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { SalonPolicy } from '../authorization/policies/salon.policy'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { UserRole } from '../users/enums/user-role.enum'; import { UsersService } from '../users/users.service'; import { CreateSalonDto } from './dto/create-salon.dto'; @@ -17,15 +20,28 @@ export class SalonsService { @InjectRepository(Salon) private readonly salonsRepository: Repository, private readonly usersService: UsersService, + private readonly salonPolicy: SalonPolicy, ) {} - async create(createSalonDto: CreateSalonDto): Promise { - await this.ensureOwnerIsSalonOwner(createSalonDto.ownerId); - const salon = this.salonsRepository.create(createSalonDto); + async create(createSalonDto: CreateSalonDto, user: AuthUser): Promise { + const ownerId = this.resolveOwnerId(createSalonDto, user); + await this.ensureOwnerIsSalonOwner(ownerId); + + const salon = this.salonsRepository.create({ + ...createSalonDto, + ownerId, + }); return this.salonsRepository.save(salon); } - findAll(): Promise { + findAll(user?: AuthUser): Promise { + if (user?.role === UserRole.SALON) { + return this.salonsRepository.find({ + where: { ownerId: user.id }, + relations: { owner: true }, + }); + } + return this.salonsRepository.find({ relations: { owner: true } }); } @@ -40,17 +56,42 @@ export class SalonsService { return salon; } - async update(id: string, updateSalonDto: UpdateSalonDto): Promise { + async update( + id: string, + updateSalonDto: UpdateSalonDto, + user: AuthUser, + ): Promise { const salon = await this.findOne(id); + if (!this.salonPolicy.canUpdate(user, salon)) { + throw new ForbiddenException(); + } Object.assign(salon, updateSalonDto); return this.salonsRepository.save(salon); } - async remove(id: string): Promise { + async remove(id: string, user: AuthUser): Promise { const salon = await this.findOne(id); + if (!this.salonPolicy.canDelete(user, salon)) { + throw new ForbiddenException(); + } await this.salonsRepository.remove(salon); } + private resolveOwnerId( + createSalonDto: CreateSalonDto, + user: AuthUser, + ): string { + if (user.role === UserRole.SALON) { + return user.id; + } + + if (!createSalonDto.ownerId) { + throw new BadRequestException('ownerId is required for admin'); + } + + return createSalonDto.ownerId; + } + private async ensureOwnerIsSalonOwner(ownerId: string): Promise { const owner = await this.usersService.findOne(ownerId); if (owner.role !== UserRole.SALON) { diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts new file mode 100644 index 0000000..e7a1d05 --- /dev/null +++ b/src/sms/sms.module.ts @@ -0,0 +1,10 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + imports: [HttpModule], + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts new file mode 100644 index 0000000..b67534c --- /dev/null +++ b/src/sms/sms.service.ts @@ -0,0 +1,60 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + private readonly apiKey: string; + private readonly sender: string; + private readonly baseUrl: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.apiKey = this.configService.get('SMS_API_KEY', ''); + this.sender = this.configService.get('SMS_SENDER', ''); + this.baseUrl = + this.configService.get('SMS_API_BASE_URL') ?? + 'https://api.sms-webservice.com/api/V3'; + } + + async sendSms(mobile: string, message: string): Promise { + if (!this.apiKey || !this.sender) { + this.logger.error('SMS_API_KEY or SMS_SENDER is not configured'); + return false; + } + try { + const url = new URL(`${this.baseUrl}/Send`); + url.searchParams.append('ApiKey', this.apiKey); + url.searchParams.append('Sender', this.sender); + url.searchParams.append('Recipients', mobile); + url.searchParams.append('Text', message); + + await firstValueFrom(this.httpService.get(url.toString())); + return true; + } catch (error) { + this.logger.error(`Failed to send SMS to ${mobile}`, error); + return false; + } + } + + async sendMultipleSms( + recipients: Array<{ mobile: string; message: string }>, + ): Promise { + try { + await firstValueFrom( + this.httpService.post(`${this.baseUrl}/SendMultiple`, { + ApiKey: this.apiKey, + Recipients: recipients, + }), + ); + return true; + } catch (error) { + this.logger.error('Failed to send multiple SMS', error); + return false; + } + } +} diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 339c385..56f1bb5 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -1,4 +1,5 @@ -import { IsOptional, IsString, Length, Matches } from 'class-validator'; +import { IsEnum, IsOptional, IsString, Length, Matches } from 'class-validator'; +import { UserRole } from '../enums/user-role.enum'; export class UpdateUserDto { @IsOptional() @@ -14,4 +15,8 @@ export class UpdateUserDto { @IsOptional() @Matches(/^09\d{9}$/) mobile?: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index a3af450..ef313a1 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -2,32 +2,53 @@ import { Body, Controller, Delete, + ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, + UseGuards, } from '@nestjs/common'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { UserRole } from './enums/user-role.enum'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; @Controller('users') +@UseGuards(RolesGuard, PermissionsGuard) export class UsersController { constructor(private readonly usersService: UsersService) {} + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.USERS_WRITE) @Post() create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); } + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.USERS_READ) @Get() findAll() { return this.usersService.findAll(); } @Get(':id') - findOne(@Param('id', ParseUUIDPipe) id: string) { + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + if (user.role !== UserRole.ADMIN && user.id !== id) { + throw new ForbiddenException(); + } return this.usersService.findOne(id); } @@ -35,10 +56,16 @@ export class UsersController { update( @Param('id', ParseUUIDPipe) id: string, @Body() updateUserDto: UpdateUserDto, + @CurrentUser() user: AuthUser, ) { - return this.usersService.update(id, updateUserDto); + if (user.role !== UserRole.ADMIN && user.id !== id) { + throw new ForbiddenException(); + } + return this.usersService.update(id, updateUserDto, user); } + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.USERS_DELETE) @Delete(':id') remove(@Param('id', ParseUUIDPipe) id: string) { return this.usersService.remove(id); diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 12a46ed..9f3ea8f 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; import { User } from './entities/user.entity'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User]), AuthGuardsModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 17ca5fa..23ca645 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,13 +1,16 @@ import { ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { User } from './entities/user.entity'; +import { UserRole } from './enums/user-role.enum'; @Injectable() export class UsersService { @@ -34,11 +37,25 @@ export class UsersService { return user; } - async update(id: string, updateUserDto: UpdateUserDto): Promise { + async findByMobile(mobile: string): Promise { + return this.usersRepository.findOneBy({ mobile }); + } + + async update( + id: string, + updateUserDto: UpdateUserDto, + currentUser?: AuthUser, + ): Promise { const user = await this.findOne(id); if (updateUserDto.mobile) { await this.ensureMobileIsUnique(updateUserDto.mobile, id); } + if ( + updateUserDto.role !== undefined && + currentUser?.role !== UserRole.ADMIN + ) { + throw new ForbiddenException('Only admin can change user role'); + } Object.assign(user, updateUserDto); return this.usersRepository.save(user); }