diff --git a/backend/package-lock.json b/backend/package-lock.json index 133bbdf..b53a385 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,17 +12,22 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.0", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0", "@types/uuid": "^10.0.0", + "bcryptjs": "^3.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pdfkit": "^0.17.2", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", @@ -34,10 +39,12 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcryptjs": "^3.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/node": "^20.3.1", + "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.3", "@types/pg": "^8.10.0", "@types/supertest": "^6.0.0", @@ -1702,6 +1709,18 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.0.tgz", + "integrity": "sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==", + "dependencies": { + "@types/jsonwebtoken": "9.0.7", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", @@ -1722,6 +1741,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "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": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", @@ -1818,6 +1846,11 @@ } } }, + "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", + "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -2100,6 +2133,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", + "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", + "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "bcryptjs": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2234,6 +2277,14 @@ "@types/node": "*" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2262,6 +2313,35 @@ "undici-types": "~6.21.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, + "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, + "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, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/pdfkit": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.3.tgz", @@ -3170,6 +3250,14 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3334,6 +3422,11 @@ "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==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4061,6 +4154,14 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "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==", + "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", @@ -6447,6 +6548,46 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "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": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6554,6 +6695,36 @@ "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6566,6 +6737,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7126,6 +7302,40 @@ "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==", + "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==", + "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", @@ -7192,6 +7402,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/pdfkit": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", @@ -7966,7 +8181,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -9341,19 +9555,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "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/backend/package.json b/backend/package.json index e251098..094a4fd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,17 +23,22 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^11.0.0", "@nestjs/mapped-types": "*", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0", "@types/uuid": "^10.0.0", + "bcryptjs": "^3.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pdfkit": "^0.17.2", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", @@ -45,10 +50,12 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/bcryptjs": "^3.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", "@types/node": "^20.3.1", + "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.3", "@types/pg": "^8.10.0", "@types/supertest": "^6.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1728a61..434c5e6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { Department } from './departments/department.entity'; import { UsersModule } from './users/users.module'; import { User } from './users/entities/user.entity'; import { SearchModule } from './search/search.module'; +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { SearchModule } from './search/search.module'; AssetTransfersModule, UsersModule, SearchModule, + AuthModule, ], controllers: [AppController, NotificationsController], providers: [AppService, NotificationsService], diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..6279896 --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -0,0 +1,39 @@ + +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: Partial; + + beforeEach(async () => { + authService = { + login: jest.fn().mockResolvedValue({ accessToken: 'token', refreshToken: 'refresh' }), + validateUser: jest.fn().mockResolvedValue({ id: '1', email: 'test@example.com', role: 'user' }), + refresh: jest.fn().mockResolvedValue({ accessToken: 'token2', refreshToken: 'refresh2' }), + }; + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [{ provide: AuthService, useValue: authService }], + }).compile(); + + controller = module.get(AuthController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should login and return tokens', async () => { + const result = await controller.login({ email: 'test@example.com', password: 'password' }); + expect(result.accessToken).toBe('token'); + expect(result.refreshToken).toBe('refresh'); + }); + + it('should refresh and return new tokens', async () => { + const result = await controller.refresh({ userId: '1', refreshToken: 'refresh' }); + expect(result.accessToken).toBe('token2'); + expect(result.refreshToken).toBe('refresh2'); + }); +}); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..35754ee --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,20 @@ + +import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + async login(@Body() body: { email: string; password: string }) { + const user = await this.authService.validateUser(body.email, body.password); + if (!user) throw new UnauthorizedException('Invalid credentials'); + return this.authService.login(user); + } + + @Post('refresh') + async refresh(@Body() body: { userId: string; refreshToken: string }) { + return this.authService.refresh(body.userId, body.refreshToken); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..50b470f --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,21 @@ + +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET || 'supersecret', + signOptions: { expiresIn: '15m' }, + }), + ], + providers: [AuthService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..b9dea64 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,71 @@ + +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/entities/user.entity'; + +describe('AuthService', () => { + let service: AuthService; + let usersService: Partial; + let jwtService: JwtService; + const mockUser: User = { + id: '1', + email: 'test@example.com', + passwordHash: '$2a$10$testhash', + fullName: 'Test User', + role: 'user', + phoneNumber: '', + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + usersService = { + userRepository: { + findOneBy: jest.fn().mockResolvedValue(mockUser), + }, + findOne: jest.fn().mockResolvedValue(mockUser), + } as any; + jwtService = new JwtService({ + secret: 'test', + signOptions: { expiresIn: '15m' }, + }); + service = new AuthService(usersService as UsersService, jwtService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should validate user with correct password', async () => { + jest.spyOn(require('bcryptjs'), 'compare').mockResolvedValue(true); + const user = await service.validateUser('test@example.com', 'password'); + expect(user).toBeDefined(); + expect(user?.email).toBe('test@example.com'); + }); + + it('should not validate user with wrong password', async () => { + jest.spyOn(require('bcryptjs'), 'compare').mockResolvedValue(false); + const user = await service.validateUser('test@example.com', 'wrong'); + expect(user).toBeNull(); + }); + + it('should login and return tokens', async () => { + const tokens = await service.login(mockUser); + expect(tokens.accessToken).toBeDefined(); + expect(tokens.refreshToken).toBeDefined(); + }); + + it('should refresh token if valid', async () => { + const tokens = await service.login(mockUser); + const refreshed = await service.refresh(mockUser.id, tokens.refreshToken); + expect(refreshed.accessToken).toBeDefined(); + expect(refreshed.refreshToken).toBeDefined(); + }); + + it('should throw on invalid refresh token', async () => { + await service.login(mockUser); + await expect(service.refresh(mockUser.id, 'invalid')).rejects.toThrow(); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..5fccff6 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,41 @@ + +import { Injectable, UnauthorizedException, Inject, forwardRef } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import * as bcrypt from 'bcryptjs'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class AuthService { + private refreshTokens: Map = new Map(); // userId -> refreshToken + + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async validateUser(email: string, password: string): Promise { + const user = await this.usersService.findByEmail(email); + if (!user) return null; + const isMatch = await bcrypt.compare(password, user.passwordHash); + return isMatch ? user : null; + } + + async login(user: User) { + const payload = { sub: user.id, email: user.email, role: user.role }; + const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); + const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); + this.refreshTokens.set(user.id, refreshToken); + return { accessToken, refreshToken }; + } + + async refresh(userId: string, refreshToken: string) { + const storedToken = this.refreshTokens.get(userId); + if (!storedToken || storedToken !== refreshToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + const user = await this.usersService.findOne(userId); + if (!user) throw new UnauthorizedException('User not found'); + return this.login(user); + } +} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index b4e6074..6ebc3e2 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -24,6 +24,10 @@ export class UsersService { return this.userRepository.save(user); } + async findByEmail(email: string): Promise { + return this.userRepository.findOneBy({ email }); + } + async findAll(page = 1, limit = 10, role?: string): Promise<{ data: User[]; total: number }> { const [data, total] = await this.userRepository.findAndCount({ where: role ? { role: role as 'admin' | 'user' | 'manager' } : {},