diff --git a/backend/.env.example b/backend/.env.example index 41fd4ce5e..ca282092c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,12 @@ DB_DIALECT=pg DB_POOL_MIN=2 DB_POOL_MAX=10 +# +# TOKEN +# +SECRET_KEY=IfcxMIXtiR3LC2CX0inOB3ozBuuTYqb7 +EXPIRATION_TIME=24h + # # AWS # @@ -30,3 +36,4 @@ OPEN_AI_KEY=SOME_SECRET_KEY # SESSION # SESSION_KEY=SOME_SECRET_KEY + diff --git a/backend/package.json b/backend/package.json index 7d9343596..dfdb2b430 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,8 @@ "convict": "6.2.4", "dotenv": "16.4.5", "fastify": "4.28.1", + "fastify-plugin": "4.5.1", + "jose": "5.7.0", "knex": "3.1.0", "objection": "3.1.4", "openai": "4.56.0", diff --git a/backend/src/bundles/auth/auth.service.ts b/backend/src/bundles/auth/auth.service.ts index 1372f1a8d..e5d0b8a1e 100644 --- a/backend/src/bundles/auth/auth.service.ts +++ b/backend/src/bundles/auth/auth.service.ts @@ -8,7 +8,7 @@ import { type UserSignInResponseDto, } from '~/bundles/users/users.js'; import { HttpCode, HttpError } from '~/common/http/http.js'; -import { cryptService } from '~/common/services/services.js'; +import { cryptService, tokenService } from '~/common/services/services.js'; import { UserValidationMessage } from './enums/enums.js'; @@ -46,7 +46,10 @@ class AuthService { }); } - return user.toObject(); + const userObject = user.toObject(); + const { id } = userObject; + const token = await tokenService.createToken(id); + return { ...userObject, token }; } public async signUp( @@ -60,7 +63,10 @@ class AuthService { status: HttpCode.BAD_REQUEST, }); } - return this.userService.create(userRequestDto); + const user = await this.userService.create(userRequestDto); + const { id } = user; + const token = await tokenService.createToken(id); + return { ...user, token }; } } diff --git a/backend/src/bundles/users/user.repository.ts b/backend/src/bundles/users/user.repository.ts index af68505e8..84705a423 100644 --- a/backend/src/bundles/users/user.repository.ts +++ b/backend/src/bundles/users/user.repository.ts @@ -1,7 +1,8 @@ -import { UserEntity } from '~/bundles/users/user.entity.js'; import { type UserModel } from '~/bundles/users/user.model.js'; import { type Repository } from '~/common/types/types.js'; +import { UserEntity } from '../../bundles/users/user.entity.js'; + class UserRepository implements Repository { private userModel: typeof UserModel; @@ -9,8 +10,10 @@ class UserRepository implements Repository { this.userModel = userModel; } - public find(): ReturnType { - return Promise.resolve(null); + public async find(userId: string): Promise { + const user = await this.userModel.query().findById(userId).execute(); + + return user ? UserEntity.initialize(user) : null; } public async findByEmail(email: string): Promise { diff --git a/backend/src/bundles/users/user.service.ts b/backend/src/bundles/users/user.service.ts index b55b0c301..ee3a0e061 100644 --- a/backend/src/bundles/users/user.service.ts +++ b/backend/src/bundles/users/user.service.ts @@ -1,8 +1,8 @@ -import { UserEntity } from '~/bundles/users/user.entity.js'; import { type UserRepository } from '~/bundles/users/user.repository.js'; import { cryptService } from '~/common/services/services.js'; import { type Service } from '~/common/types/types.js'; +import { UserEntity } from '../../bundles/users/user.entity.js'; import { type UserGetAllResponseDto, type UserSignUpRequestDto, @@ -16,8 +16,8 @@ class UserService implements Service { this.userRepository = userRepository; } - public find(): ReturnType { - return Promise.resolve(null); + public async find(id: string): Promise { + return await this.userRepository.find(id); } public async findByEmail(email: string): Promise { diff --git a/backend/src/bundles/users/users.ts b/backend/src/bundles/users/users.ts index 73f640e85..3c7531b73 100644 --- a/backend/src/bundles/users/users.ts +++ b/backend/src/bundles/users/users.ts @@ -16,6 +16,7 @@ export { type UserSignUpRequestDto, type UserSignUpResponseDto, } from './types/types.js'; +export { type UserEntity } from './user.entity.js'; export { UserModel } from './user.model.js'; export { userSignInValidationSchema, diff --git a/backend/src/common/config/base-config.package.ts b/backend/src/common/config/base-config.package.ts index 853871b74..896ba41ef 100644 --- a/backend/src/common/config/base-config.package.ts +++ b/backend/src/common/config/base-config.package.ts @@ -74,6 +74,20 @@ class BaseConfig implements Config { default: null, }, }, + TOKEN: { + SECRET_KEY: { + doc: 'Secret key for token generation', + format: String, + env: 'SECRET_KEY', + default: null, + }, + EXPIRATION_TIME: { + doc: 'Token expiration time', + format: String, + env: 'EXPIRATION_TIME', + default: null, + }, + }, AWS: { ACCESS_KEY_ID: { doc: 'AWS access key id', diff --git a/backend/src/common/config/types/environment-schema.type.ts b/backend/src/common/config/types/environment-schema.type.ts index cbc23223f..b6fb22f03 100644 --- a/backend/src/common/config/types/environment-schema.type.ts +++ b/backend/src/common/config/types/environment-schema.type.ts @@ -13,6 +13,10 @@ type EnvironmentSchema = { POOL_MIN: number; POOL_MAX: number; }; + TOKEN: { + SECRET_KEY: string; + EXPIRATION_TIME: string; + }; AWS: { ACCESS_KEY_ID: string; SECRET_ACCESS_KEY: string; diff --git a/backend/src/common/constants/constants.ts b/backend/src/common/constants/constants.ts index 172cdf50f..1a5d15784 100644 --- a/backend/src/common/constants/constants.ts +++ b/backend/src/common/constants/constants.ts @@ -1 +1,2 @@ export { USER_PASSWORD_SALT_ROUNDS } from './user.constants.js'; +export { WHITE_ROUTES } from './white-routes.constants.js'; diff --git a/backend/src/common/constants/white-routes.constants.ts b/backend/src/common/constants/white-routes.constants.ts new file mode 100644 index 000000000..faf1348e1 --- /dev/null +++ b/backend/src/common/constants/white-routes.constants.ts @@ -0,0 +1,14 @@ +import { ApiPath, AuthApiPath } from 'shared'; + +const WHITE_ROUTES = [ + { + path: `/api/v1${ApiPath.AUTH}${AuthApiPath.SIGN_IN}`, + method: 'POST', + }, + { + path: `/api/v1${ApiPath.AUTH}${AuthApiPath.SIGN_UP}`, + method: 'POST', + }, +]; + +export { WHITE_ROUTES }; diff --git a/backend/src/common/plugins/auth/auth-jwt.plugin.ts b/backend/src/common/plugins/auth/auth-jwt.plugin.ts new file mode 100644 index 000000000..9a5c77635 --- /dev/null +++ b/backend/src/common/plugins/auth/auth-jwt.plugin.ts @@ -0,0 +1,58 @@ +import fp from 'fastify-plugin'; +import { HttpCode, HttpError, HttpHeader } from 'shared'; + +import { userService } from '~/bundles/users/users.js'; +import { tokenService } from '~/common/services/services.js'; + +import { ErrorMessage, Hook } from './enums/enums.js'; +import { type Route } from './types/types.js'; +import { isRouteInWhiteList } from './utils/utils.js'; + +type Options = { + routesWhiteList: Route[]; +}; + +const authenticateJWT = fp((fastify, { routesWhiteList }, done) => { + fastify.decorateRequest('user', null); + + fastify.addHook(Hook.PRE_HANDLER, async (request) => { + if (isRouteInWhiteList(routesWhiteList, request)) { + return; + } + + const authHeader = request.headers[HttpHeader.AUTHORIZATION]; + + if (!authHeader) { + throw new HttpError({ + message: ErrorMessage.MISSING_TOKEN, + status: HttpCode.UNAUTHORIZED, + }); + } + + const [, token] = authHeader.split(' '); + + const userId = await tokenService.getUserIdFromToken(token as string); + + if (!userId) { + throw new HttpError({ + message: ErrorMessage.INVALID_TOKEN, + status: HttpCode.UNAUTHORIZED, + }); + } + + const user = await userService.find(userId); + + if (!user) { + throw new HttpError({ + message: ErrorMessage.MISSING_USER, + status: HttpCode.BAD_REQUEST, + }); + } + + request.user = user; + }); + + done(); +}); + +export { authenticateJWT }; diff --git a/backend/src/common/plugins/auth/enums/enums.ts b/backend/src/common/plugins/auth/enums/enums.ts new file mode 100644 index 000000000..ab66478a4 --- /dev/null +++ b/backend/src/common/plugins/auth/enums/enums.ts @@ -0,0 +1,2 @@ +export { ErrorMessage } from './error-message.enum.js'; +export { Hook } from './hook.enum.js'; diff --git a/backend/src/common/plugins/auth/enums/error-message.enum.ts b/backend/src/common/plugins/auth/enums/error-message.enum.ts new file mode 100644 index 000000000..58e565b33 --- /dev/null +++ b/backend/src/common/plugins/auth/enums/error-message.enum.ts @@ -0,0 +1,7 @@ +const ErrorMessage = { + MISSING_TOKEN: 'You are not logged in', + INVALID_TOKEN: 'Token is no longer valid. Please log in again.', + MISSING_USER: 'User with this id does not exist.', +} as const; + +export { ErrorMessage }; diff --git a/backend/src/common/plugins/auth/enums/hook.enum.ts b/backend/src/common/plugins/auth/enums/hook.enum.ts new file mode 100644 index 000000000..5588cd61c --- /dev/null +++ b/backend/src/common/plugins/auth/enums/hook.enum.ts @@ -0,0 +1,5 @@ +const Hook = { + PRE_HANDLER: 'preHandler', +} as const; + +export { Hook }; diff --git a/backend/src/common/plugins/auth/types/route.type.ts b/backend/src/common/plugins/auth/types/route.type.ts new file mode 100644 index 000000000..2ece81d01 --- /dev/null +++ b/backend/src/common/plugins/auth/types/route.type.ts @@ -0,0 +1,6 @@ +type Route = { + path: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +}; + +export { type Route }; diff --git a/backend/src/common/plugins/auth/types/types.ts b/backend/src/common/plugins/auth/types/types.ts new file mode 100644 index 000000000..da236e3fd --- /dev/null +++ b/backend/src/common/plugins/auth/types/types.ts @@ -0,0 +1 @@ +export { type Route } from './route.type.js'; diff --git a/backend/src/common/plugins/auth/utils/check-white-routes.util.ts b/backend/src/common/plugins/auth/utils/check-white-routes.util.ts new file mode 100644 index 000000000..09f040f31 --- /dev/null +++ b/backend/src/common/plugins/auth/utils/check-white-routes.util.ts @@ -0,0 +1,15 @@ +import { type FastifyRequest } from 'fastify'; + +import { type Route } from '../types/types.js'; + +const isRouteInWhiteList = ( + routesWhiteList: Route[], + request: FastifyRequest, +): boolean => { + return routesWhiteList.some( + (route) => + route.path === request.url && route.method === request.method, + ); +}; + +export { isRouteInWhiteList }; diff --git a/backend/src/common/plugins/auth/utils/utils.ts b/backend/src/common/plugins/auth/utils/utils.ts new file mode 100644 index 000000000..7dc2439bd --- /dev/null +++ b/backend/src/common/plugins/auth/utils/utils.ts @@ -0,0 +1 @@ +export { isRouteInWhiteList } from './check-white-routes.util.js'; diff --git a/backend/src/common/plugins/plugins.ts b/backend/src/common/plugins/plugins.ts new file mode 100644 index 000000000..edaf41ada --- /dev/null +++ b/backend/src/common/plugins/plugins.ts @@ -0,0 +1 @@ +export { authenticateJWT } from './auth/auth-jwt.plugin.js'; diff --git a/backend/src/common/server-application/base-server-app.ts b/backend/src/common/server-application/base-server-app.ts index 458e62b01..0a514c69c 100644 --- a/backend/src/common/server-application/base-server-app.ts +++ b/backend/src/common/server-application/base-server-app.ts @@ -23,6 +23,8 @@ import { type ValidationSchema, } from '~/common/types/types.js'; +import { WHITE_ROUTES } from '../constants/constants.js'; +import { authenticateJWT } from '../plugins/plugins.js'; import { type ServerApp, type ServerAppApi, @@ -124,6 +126,10 @@ class BaseServerApp implements ServerApp { } private registerPlugins(): void { + this.app.register(authenticateJWT, { + routesWhiteList: WHITE_ROUTES, + }); + this.app.register(fastifyMultipart, { limits: { fileSize: Number.POSITIVE_INFINITY, diff --git a/backend/src/common/services/services.ts b/backend/src/common/services/services.ts index 9e5d7013b..f72860f44 100644 --- a/backend/src/common/services/services.ts +++ b/backend/src/common/services/services.ts @@ -1,8 +1,13 @@ import { config } from '../config/config.js'; import { CryptService } from './crypt/crypt.service.js'; import { FileService } from './file/file.service.js'; +import { TokenService } from './token/token.services.js'; const cryptService = new CryptService(); const fileService = new FileService(config); -export { cryptService, fileService }; +const secretKey = config.ENV.TOKEN.SECRET_KEY; +const expirationTime = config.ENV.TOKEN.EXPIRATION_TIME; +const tokenService = new TokenService(secretKey, expirationTime); + +export { cryptService, fileService, tokenService }; diff --git a/backend/src/common/services/token/token.services.ts b/backend/src/common/services/token/token.services.ts new file mode 100644 index 000000000..3bef998a0 --- /dev/null +++ b/backend/src/common/services/token/token.services.ts @@ -0,0 +1,35 @@ +import { type JWTPayload as TokenPayload } from 'jose'; +import { jwtVerify, SignJWT } from 'jose'; + +class TokenService { + private secretKey: Uint8Array; + private expirationTime: string; + + public constructor(secretKey: string, expirationTime: string) { + this.secretKey = new TextEncoder().encode(secretKey); + this.expirationTime = expirationTime; + } + + public async createToken(userId: string): Promise { + return await new SignJWT({ userId }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(this.expirationTime) + .sign(this.secretKey); + } + + public async verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, this.secretKey); + return payload; + } catch { + return null; + } + } + + public async getUserIdFromToken(token: string): Promise { + const payload = await this.verifyToken(token); + return (payload?.['userId'] as string) || null; + } +} + +export { TokenService }; diff --git a/backend/src/common/types/fastify.d.ts b/backend/src/common/types/fastify.d.ts new file mode 100644 index 000000000..f1b244180 --- /dev/null +++ b/backend/src/common/types/fastify.d.ts @@ -0,0 +1,9 @@ +import 'fastify'; + +import { type UserEntity } from '~/bundles/users/users.js'; + +declare module 'fastify' { + interface FastifyRequest { + user: UserEntity; + } +} diff --git a/package-lock.json b/package-lock.json index 8dcddd8bc..090f8cb6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,8 @@ "convict": "6.2.4", "dotenv": "16.4.5", "fastify": "4.28.1", + "fastify-plugin": "4.5.1", + "jose": "5.7.0", "knex": "3.1.0", "objection": "3.1.4", "openai": "4.56.0", @@ -10178,6 +10180,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.7.0.tgz", + "integrity": "sha512-3P9qfTYDVnNn642LCAqIKbTGb9a1TBxZ9ti5zEVEr48aDdflgRjhspWFb6WM4PzAfFbGMJYC4+803v8riCRAKw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/shared/src/bundles/users/types/user-sign-in-response-dto.type.ts b/shared/src/bundles/users/types/user-sign-in-response-dto.type.ts index c719c9f58..0cb1beff6 100644 --- a/shared/src/bundles/users/types/user-sign-in-response-dto.type.ts +++ b/shared/src/bundles/users/types/user-sign-in-response-dto.type.ts @@ -1,6 +1,7 @@ type UserSignInResponseDto = { id: string; email: string; + token?: string; }; export { type UserSignInResponseDto }; diff --git a/shared/src/bundles/users/types/user-sign-up-response-dto.type.ts b/shared/src/bundles/users/types/user-sign-up-response-dto.type.ts index d1fa178b8..a9c84171f 100644 --- a/shared/src/bundles/users/types/user-sign-up-response-dto.type.ts +++ b/shared/src/bundles/users/types/user-sign-up-response-dto.type.ts @@ -2,6 +2,7 @@ type UserSignUpResponseDto = { id: string; fullName: string; email: string; + token?: string; }; export { type UserSignUpResponseDto }; diff --git a/shared/src/framework/http/enums/http-code.enum.ts b/shared/src/framework/http/enums/http-code.enum.ts index f4ea2347e..6aa810c91 100644 --- a/shared/src/framework/http/enums/http-code.enum.ts +++ b/shared/src/framework/http/enums/http-code.enum.ts @@ -5,6 +5,8 @@ const HttpCode = { NOT_FOUND: 404, UNPROCESSED_ENTITY: 422, INTERNAL_SERVER_ERROR: 500, + UNAUTHORIZED: 401, + FORBIDDEN: 403, } as const; export { HttpCode };