From d550f3fe22042b9d9b15c52f6efccd6a4f29bf86 Mon Sep 17 00:00:00 2001 From: lwvemike Date: Tue, 11 Jun 2024 17:18:30 +0300 Subject: [PATCH] docs: add impl details --- apps/README.md | 138 +++++++++++++ apps/backend/database/seeds/index.ts | 191 ++++++++++++++++++ apps/backend/package.json | 5 + apps/backend/src/app.module.ts | 9 +- apps/backend/src/classes/Tree.ts | 86 ++++++++ apps/backend/src/classes/permission.ts | 0 apps/backend/src/main.ts | 4 + .../authentication.controller.ts | 12 -- .../authentication/authentication.guard.ts | 80 +++++++- .../authentication/authentication.service.ts | 8 +- apps/backend/src/modules/drizzle/schema.ts | 57 +++++- apps/backend/src/modules/jwt/jwt.service.ts | 139 ++++++++++++- .../modules/protected/protected.controller.ts | 56 ++++- .../src/modules/session/session.middleware.ts | 40 +++- .../src/modules/session/session.module.ts | 11 +- .../src/modules/session/session.service.ts | 29 ++- apps/frontend/src/App.vue | 16 +- .../pages/home/components/AccessButtons.vue | 48 +++++ apps/frontend/src/pages/home/index.vue | 11 + pnpm-lock.yaml | 57 ++++++ 20 files changed, 914 insertions(+), 83 deletions(-) create mode 100644 apps/README.md create mode 100644 apps/backend/database/seeds/index.ts create mode 100644 apps/backend/src/classes/Tree.ts create mode 100644 apps/backend/src/classes/permission.ts create mode 100644 apps/frontend/src/pages/home/components/AccessButtons.vue diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..157bed1 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,138 @@ +# NFA Authentication + +## Authentication Flow description + +### Preface + +The authentication flow is based on 2 jwt tokens that will be saved in cookies: + +- Access token: + - Should be short lived. + - Will contain non-sensitive information that the frontend can decode. + - Will be signed with the private key of the access token. + - Will be used to authenticate the user. + +- Refresh token: + - Should be long lived. + - Will contain information that will link between the browser session to the session in the database. + - Will be signed with the private key of the refresh token. + - Will be used to refresh the access token. + +### How it will work + +By our norms, we will consider an authenticated request to the API if the user: + +- Sends both `access-token` and `refresh-token` and they are both valid. +- Sends an expired `access-token` and a valid `refresh-token`: + - The `access-token` will be refreshed and the `refresh-token` will be rotated [ The rotation of the `refresh-token` is not yet decided ]. + +There will also be endpoints that doesn't require authentication. + +The backend should clean any inconsistencies with the cookies: If the `access-token` is expired, this means someone changed the lifetime of the cookie on the client side, this means the backend should delete the cookie from the response. And others such cases. + +The frontend should implement a composables that will handle the authentication flow based on the access token. + +**Cookies** +The only difference between the `cookie options` for `access-token` and `refresh-token` is the `httpOnly` option. Which should be set to `true` for the `refresh-token` and `false` for the `access-token`. This means that the `access-token` can be read by the `javascript` code in the browser, but the `refresh-token` is protected. + +The expires of the cookies should be calculated based on the lifetime of the token. + +Also the remaining options for cookies should be set to the most strictest mode. + +**Database** +Session table should look like this: + +- id: Primary Key +- userId: Foreign Key to the user table +- last_accessed_at: Last time the session was accessed + - defaults to the current time ( used when the session is created ) +- expires_at: Expiration time of the session + - defaults to the current time + the lifetime of the token +- device: Device that the session was created on + - defaults to 'unknown' +- os: Operating system of the device + - defaults to 'unknown' +- ip: IP address of the device + - defaults to null + +**Role** +Role table should look like this: + +## Requirements + +### NFA initialization + +We should create the keys when NFA is deployed on the clients' servers. + +The way I generated them in the investigation phase was: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out "$TOKEN_PREFIX-priv-key.pem" +openssl ec -in "$TOKEN_PREFIX-priv-key.pem" -pubout > "$TOKEN_PREFIX-pub-key.pem" +``` + +Where `$TOKEN_PREFIX` is the type of the token, eg. `access-token`. + +If the person that will be doing this task, has a better way to do it, please let me know ( because we should change some things in the way the tokens are signed ). + +The paths of the keys generated should be added to config. + +### Config + +```json +{ + // Path to public key for access token + "jwt.access-token.pub.key": "/path/to/access-token/public.key", + // Path to private key for access token + "jwt.access-token.priv.key": "/path/to/access-token/public.key", + /* Expiry should be represented in seconds, because we will do some calculations on it, for cookies and database time. + */ + // Other formats supported: Eg: 60, "2 days", "10h", "7d" + "jwt.access-token.expiry": 3600 // 1 hour, + + // Path to public key for refresh token + "jwt.refresh-token.pub.key": "/path/to/refresh-token/public.key", + // Path to private key for refresh token + "jwt.refresh-token.priv.key": "/path/to/refresh-token/public.key", + // Same format as jwt.access-token.expiry + "jwt.refresh-token.expiry": 1209600 // 2 weeks +} +``` + +## Implementation + +### NAPID + +**ConfigService** +The service should be able to read the config file that is the `node-addon` from `c++` and from `.env` file located in `web`. + +This service should also validate the values with `zod` and read the keys needed for `access-token` and `refresh-token`. + +**RequestMetaService** +This service should have an internal private `WeakMap` that will use the `req` object as the key and decoded `access-token` as value. + +Also is should have public methods that will allow setting and getting the value with the `req` argument. + +**JwtService** +This will be used to create, sign, decode tokends, create cookies options and calculate the expiries of cookies. + +**SessionService** +This will be used to create sessions, extract data from user agent and the forwarded ip from the `req` and also provide the session id for `refresh-token`. + +**AuthenticationMiddleware** +This should use the `JwtService` to validate the tokens and as described in the Authentication Flow should handle all the cases. + +This will be a global middleware and would run on all the endpoints except the one excluded. + +In the **WebsocketsGateway** should be implemented code that will notify the user when a new session is created. + +**Frontend** +Should have a composable that will take care of the authentication. + +**Session Part Page** +We need to implement all the endpoints related to session management, and by the design from figma, the card that will allow to invalidate sessions. + +**Task for cleaning stale session** +If by any scenario there will be a old session tied to nothing left in the database we should have a job that will clear that. + +For a Proof of concept, you can access this repository: [nfa-jwt-poc](https://github.com/LwveMike/nfa-jwt-poc) diff --git a/apps/backend/database/seeds/index.ts b/apps/backend/database/seeds/index.ts new file mode 100644 index 0000000..1eac879 --- /dev/null +++ b/apps/backend/database/seeds/index.ts @@ -0,0 +1,191 @@ +import { config, exit } from 'node:process' +import { drizzle } from 'drizzle-orm/mysql2' +import { createConnection } from 'mysql2/promise' +import * as schema from 'src/modules/drizzle/schema' + +async function main() { + const db = drizzle( + await createConnection({ + host: 'localhost', + user: 'user', + password: 'user', + database: 'nfa', + port: 3306, + }), + { + mode: 'default', + schema, + }, + ) + + async function case1() { + const { 0: role } = await db + .insert(schema.role) + .values({ + name: 'user_role', + }) + + await db + .insert(schema.user) + .values({ + username: 'username', + password: 'username', + roleId: role.insertId, + }) + + const { 0: service } = await db + .insert(schema.services) + .values({ + name: 'protected', + }) + + const { 0: servicePermission1 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'read', + }, + ]) + + const { 0: servicePermission2 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'change', + }, + ]) + + const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId] + + for (const id of lastServicePermissionIds) { + await db + .insert(schema.roleServicesPermissions) + .values([{ + roleId: role.insertId, + servicePermissionId: id, + }, + ]) + } + } + + async function case2() { + const { 0: role } = await db + .insert(schema.role) + .values({ + name: 'superadmin_role', + }) + + await db + .insert(schema.user) + .values({ + username: 'superadmin', + password: 'superadmin', + roleId: role.insertId, + }) + + const { 0: service } = await db + .insert(schema.services) + .values({ + name: 'api', + }) + + const { 0: servicePermission1 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'read', + }, + ]) + + + const { 0: servicePermission2 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'change', + }, + ]) + + const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId] + + for (const id of lastServicePermissionIds) { + await db + .insert(schema.roleServicesPermissions) + .values([{ + roleId: role.insertId, + servicePermissionId: id, + }, + ]) + } + } + + async function case3() { + const { 0: role } = await db + .insert(schema.role) + .values({ + name: 'random_role', + }) + + await db + .insert(schema.user) + .values({ + username: 'randomuser', + password: 'randomuser', + roleId: role.insertId, + }) + + async function createService(name: string) { + const { 0: service } = await db + .insert(schema.services) + .values({ + name, + }) + + const { 0: servicePermission1 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'read', + }, + ]) + + const { 0: servicePermission2 } = await db + .insert(schema.servicesPermission) + .values([ + { + serviceId: service.insertId, + permission: 'change', + }, + ]) + + const lastServicePermissionIds = [servicePermission1.insertId, servicePermission2.insertId] + + for (const id of lastServicePermissionIds) { + await db + .insert(schema.roleServicesPermissions) + .values([{ + roleId: role.insertId, + servicePermissionId: id, + }, + ]) + } + } + + for (const name of ['books', 'books.hello', 'random']) { + await createService(name) + } + } + + await case1() + await case2() + await case3() + + exit(0) +} + +main() diff --git a/apps/backend/package.json b/apps/backend/package.json index d9664af..5cdb33e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -26,6 +26,8 @@ "date-fns": "^3.6.0", "drizzle-orm": "^0.31.0", "jsonwebtoken": "^9.0.2", + "lodash.groupby": "^4.6.0", + "lodash.set": "^4.3.2", "mysql2": "^3.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -41,6 +43,8 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.6", + "@types/lodash.groupby": "^4.6.9", + "@types/lodash.set": "^4.3.9", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.39", @@ -50,6 +54,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "esno": "^4.7.0", "jest": "^29.5.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index de30b66..2e374b3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -17,13 +17,6 @@ import { SessionModule } from './modules/session/session.module' ProtectedModule, ], }) -export class AppModule implements NestModule { +export class AppModule { static readonly GLOBAL_PREFIX = '/api' - - configure(consumer: MiddlewareConsumer) { - consumer - .apply(SessionMiddleware) - .exclude(...SessionMiddleware.EXCLUDE_ROUTES) - .forRoutes('*') - } } diff --git a/apps/backend/src/classes/Tree.ts b/apps/backend/src/classes/Tree.ts new file mode 100644 index 0000000..f59ae3d --- /dev/null +++ b/apps/backend/src/classes/Tree.ts @@ -0,0 +1,86 @@ +// type Permission = 'read' | 'write' | 'delete' + +// const PERMISSION_SYMBOL = Symbol('permissions') + +// interface NodeValue { +// service: string +// [PERMISSION_SYMBOL]: Permission[] +// } + +// export class Node { +// public readonly value: NodeValue +// public readonly child: Node | null +// public readonly parent: Node | null + +// constructor( +// value: NodeValue, +// child: Node | null = null, +// parent: Node | null = null, +// ) { +// this.value = value +// this.child = child +// this.parent = parent +// } + +// static createNodeValue(service: string, permissions: Permission[]): NodeValue { +// return Object.defineProperty({ service }, PERMISSION_SYMBOL, { +// value: permissions, +// enumerable: false, +// writable: false, +// }) as NodeValue +// } +// } + +// interface NewTreeArgs { +// service: string +// permissions: Permission +// } + +// type GroupByServiceReturn = Array<{ +// service: string +// permissions: Permission[] +// }> + +// export class Tree { +// readonly #root = new Node(Node.createNodeValue('api', [])) + +// constructor(args: NewTreeArgs[]) { + +// } + +// /** +// * @description This is done, because I don't know how the query will look like +// */ +// #groupByService(args: NewTreeArgs[]) { +// return args.reduce((accumulator, current) => { +// const foundService = accumulator.find(({ service }) => service === current.service) + +// if (foundService === undefined) { +// accumulator.push({ service: current.service, permissions: [current.permissions] }) +// return accumulator +// } + +// foundService.permissions.push(current.permissions) + +// return accumulator +// }, [] as GroupByServiceReturn) +// } + +// #parseServices (args: GroupByServiceReturn) { +// const currentNode = this.#root + +// for (const { service, permissions } of args) { +// const paths = service.split('.') + +// for (let i = 0; i < paths.length; i++) { +// if (i === paths.length + 1) { +// // link +// } else { +// if (currentNode === path) { + +// } +// } +// } +// } +// } +// } diff --git a/apps/backend/src/classes/permission.ts b/apps/backend/src/classes/permission.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 27678a8..c57867e 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config' import * as cookieParser from 'cookie-parser' import type { Config } from './modules/configuration/parse-config' import { AppModule } from './app.module' +import { AuthenticationGuard } from './modules/authentication/authentication.guard' +import { SessionService } from './modules/session/session.service' async function bootstrap() { const app = await NestFactory.create(AppModule) @@ -16,7 +18,9 @@ async function bootstrap() { app.use(cookieParser()) const configService = app.get(ConfigService) + const sessionService = app.get(SessionService) + app.useGlobalGuards(new AuthenticationGuard(sessionService)); await app.listen(configService.get('port')!) } bootstrap() diff --git a/apps/backend/src/modules/authentication/authentication.controller.ts b/apps/backend/src/modules/authentication/authentication.controller.ts index da805e1..802a5bf 100644 --- a/apps/backend/src/modules/authentication/authentication.controller.ts +++ b/apps/backend/src/modules/authentication/authentication.controller.ts @@ -50,16 +50,4 @@ export class AuthenticationController { return await this.authenticationService.signIn(username, password, req, res) } - - @Get('/verify') - @HttpCode(HttpStatus.OK) - async verify(@Req() req: ExpressRequest) { - const result = z.string().safeParse(req.cookies?.[JwtService.ACCESS_TOKEN_NAME] ?? null) - - if (result.success === false) { - throw new BadRequestException('Bad token') - } - - return this.authenticationService.verify(result.data) - } } diff --git a/apps/backend/src/modules/authentication/authentication.guard.ts b/apps/backend/src/modules/authentication/authentication.guard.ts index ffb19a0..b9e5082 100644 --- a/apps/backend/src/modules/authentication/authentication.guard.ts +++ b/apps/backend/src/modules/authentication/authentication.guard.ts @@ -2,8 +2,29 @@ import type { CanActivate, ExecutionContext } from '@nestjs/common' import { Injectable, Logger } from '@nestjs/common' import type { Observable } from 'rxjs' import { z } from 'zod' +import { Request as ExpressRequest } from 'express' +import set from 'lodash.set' import { SessionService } from '../session/session.service' -import { JwtService } from '../jwt/jwt.service' +import { JwtService, ParsedServices } from '../jwt/jwt.service' + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' +type Permission = 'read' | 'change' | 'delete' + +function findNeededPermission(method: Method): Permission[] { + if (method === 'GET') { + return ['read', 'change'] + } + + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + return ['change'] + } + + if (method === 'DELETE') { + return ['delete'] + } + + throw new Error(`Unknown method: ${method}`) +} @Injectable() export class AuthenticationGuard implements CanActivate { @@ -15,17 +36,62 @@ export class AuthenticationGuard implements CanActivate { canActivate( context: ExecutionContext, ): boolean | Promise | Observable { - const request = context.switchToHttp().getRequest() + const request = context.switchToHttp().getRequest() + + // TODO(lwvemike): handle this properly + if (request.url === '/api/sign-in' || request.url === '/api/sign-up') { + return true + } + + const meta = this.sessionService.getMeta(request) - const result = z.string().safeParse(request.cookies?.[JwtService.ACCESS_TOKEN_NAME]) + if (meta === null) { + this.#logger.warn('No metafound') - if (result.success === false) { - this.#logger.error('No token found in request') return false } - const { data: token } = result + this.#logger.log(meta.services) + + console.log(request.url) + const lookupServices = request.url + .split('/') + .filter(value => value !== '') + + const permissionNeeded = findNeededPermission(request.method as Method) + + function lookup(paths: string[], services: ParsedServices) { + let lastPath: null | string = null + + for (let i = 0; i < paths.length; i += 1) { + const path = paths[i] + + if (path in services) { + if (paths.length === i + 1) { + return services[path].permissions + } + + lastPath = path + lookup(paths.slice(i + 1), services[path]) + continue + } + + if (lastPath === null) { + return null + } + + return services[lastPath].permissions + } + } + + const res = lookup(lookupServices, meta.services as ParsedServices) + + if (!res) { + this.#logger.warn('No permissions found') + + return false + } - return this.sessionService.tieSession(token) + return permissionNeeded.some(permission => res.includes(permission)) } } diff --git a/apps/backend/src/modules/authentication/authentication.service.ts b/apps/backend/src/modules/authentication/authentication.service.ts index 709e60a..c33003e 100644 --- a/apps/backend/src/modules/authentication/authentication.service.ts +++ b/apps/backend/src/modules/authentication/authentication.service.ts @@ -61,7 +61,7 @@ export class AuthenticationService { ip: req.ip, }) - const accessCookie = this.jwtService.createAccessCookie({ username: user.username }) + const accessCookie = await this.jwtService.createAccessCookie({ username: user.username, id: user.id, roleId: user.roleId! }) const refreshCookie = this.jwtService.createRefreshCookie({ sessionId }) res.cookie( @@ -78,10 +78,4 @@ export class AuthenticationService { return { success: true } } - - public verify(token: string) { - this.sessionService.tieSession(token) - - return { success: true } - } } diff --git a/apps/backend/src/modules/drizzle/schema.ts b/apps/backend/src/modules/drizzle/schema.ts index 60a645e..68f1dfe 100644 --- a/apps/backend/src/modules/drizzle/schema.ts +++ b/apps/backend/src/modules/drizzle/schema.ts @@ -1,4 +1,5 @@ -import { boolean, int, mysqlTable, text, timestamp } from 'drizzle-orm/mysql-core' +import { sql } from 'drizzle-orm' +import { UniqueConstraintBuilder, boolean, int, mysqlEnum, mysqlTable, text, timestamp, unique } from 'drizzle-orm/mysql-core' /** * @description MySQL doesn't allow for returning value when inserting @@ -12,6 +13,8 @@ export const user = mysqlTable('user', { .notNull(), password: text('password') .notNull(), + roleId: int('role_id') + .references(() => role.id) }) export const session = mysqlTable('session', { @@ -37,3 +40,55 @@ export const session = mysqlTable('session', { */ ip: text('ip'), }) + +export const services = mysqlTable('services', { + id: int('id') + .primaryKey() + .autoincrement(), + /** + * @description Name of the service corresponding to the url, or with the dot notation if is a nested service + * @example 'protected' or 'protected.books + */ + name: text('name') + .notNull(), +}) + +export const servicesPermission = mysqlTable('services_permissions', { + id: int('id') + .primaryKey() + .autoincrement(), + serviceId: int('service_id') + .references(() => services.id), + permission: mysqlEnum('permission', ['change', 'read', 'delete']) +}, (t) => { + return { + serviceIdPermission: unique('service_id__permission').on(t.serviceId, t.permission), + } +}) + +/** + * @description MySQL doesn't like role_services_permissions table name, because it created a fk to long + */ +export const roleServicesPermissions = mysqlTable('rsp', { + roleId: int('role_id') + .references(() => role.id), + servicePermissionId: int('service_permission_id') + .references(() => servicesPermission.id), +}, (t) => { + return { + roleIdServicePermissionId: unique('role_id__service_permission_id').on(t.roleId, t.servicePermissionId), + } +}) + +/** + * @description This is used more like an alias to multiple servicesPermissions + */ +export const role = mysqlTable('role', { + id: int('id') + .primaryKey() + .autoincrement(), + name: text('name') + .notNull(), +}) + + diff --git a/apps/backend/src/modules/jwt/jwt.service.ts b/apps/backend/src/modules/jwt/jwt.service.ts index 3e45764..188be02 100644 --- a/apps/backend/src/modules/jwt/jwt.service.ts +++ b/apps/backend/src/modules/jwt/jwt.service.ts @@ -1,8 +1,97 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common' +import { permission } from 'node:process' +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { TokenExpiredError, sign, verify } from 'jsonwebtoken' import type { CookieOptions } from 'express' +import { MySql2Database } from 'drizzle-orm/mysql2' +import { and, eq, inArray } from 'drizzle-orm' import { Config } from '../configuration/parse-config' +import { DATABASE_TAG } from '../drizzle/drizzle.module' +import * as schema from '../drizzle/schema' + +type Permission = 'read' | 'change' | 'delete' + +interface Service { + serviceName: string + permission: Permission[] +} + +export type ParsedServices = + & Record<'permissions', Permission[]> + & { [serviceName: string]: ParsedServices } + +interface AddPermissionsArgs { + paths: string[] + collection: ParsedServices +} + +// function addPermissions({ paths, collection, service }: AddPermissionsArgs) { +// paths.forEach((path, index) => { +// if (paths.length === index + 1) { +// collection[path] ??= { +// permissions: [], +// } as unknown as ParsedServices + +// collection[path].permissions.push(service.permission) + +// return +// } + +// if (path in collection) { +// collection[path] ??= {} as ParsedServices + +// return addPermissions({ +// paths: paths.slice(index + 1), +// collection: collection[path], +// }) +// } +// }) +// } + +function createPermissionAdder(service: Service) { + return function addPermissions({ paths, collection }: AddPermissionsArgs) { + console.log(paths) + paths.forEach((path, index) => { + if (paths.length === index + 1) { + collection[path] ??= { + // @ts-expect-error + permissions: service.permissions, + } as unknown as ParsedServices + + // collection[path].permissions.push(service.permission) + + return + } + + if (path in collection) { + collection[path] ??= {} as ParsedServices + + return addPermissions({ + paths: paths.slice(index + 1), + collection: collection[path], + }) + } + }) + } +} + +function parseServices(services: Service[]): ParsedServices { + const result = { + api: { + permissions: [], + }, + } as unknown as ParsedServices + + for (const service of services) { + const addPermissions = createPermissionAdder(service) + const paths = service.serviceName.split('.') + + console.log(paths) + addPermissions({ paths, collection: result.api }) + } + + return result +} type TokenVerificationState = | { state: 'valid', data: unknown } @@ -30,6 +119,8 @@ interface CreateTokenArgs { interface CreateAccessCookieArgs { username: string + id: number + roleId: number } interface CreateRefreshCookieArgs { @@ -42,9 +133,10 @@ export class JwtService { constructor( private readonly configService: ConfigService, + @Inject(DATABASE_TAG) private readonly drizzle: MySql2Database, ) { } - #createToken({ payload, privKey, expiresIn}: CreateTokenArgs) { + #createToken({ payload, privKey, expiresIn }: CreateTokenArgs) { try { const token = sign( payload, @@ -55,7 +147,7 @@ export class JwtService { alg: 'ES256', }, algorithm: 'ES256', - expiresIn + expiresIn, }, ) @@ -94,13 +186,50 @@ export class JwtService { } } - public createAccessCookie({ username }: CreateAccessCookieArgs) { + // HERE I ADDED some shit code to code it faster + public async createAccessCookie({ username, roleId }: CreateAccessCookieArgs) { const { privKey, expiry } = this.configService.get('accessKeys')! + const result = await this.drizzle + .select({ + serviceName: schema.services.name, + permission: schema.servicesPermission.permission, + }) + .from(schema.role) + .innerJoin(schema.roleServicesPermissions, eq(schema.role.id, schema.roleServicesPermissions.roleId)) + .innerJoin(schema.servicesPermission, eq(schema.roleServicesPermissions.servicePermissionId, schema.servicesPermission.id)) + .innerJoin(schema.services, eq(schema.servicesPermission.serviceId, schema.services.id)) + .where(eq(schema.role.id, roleId)) + + const srv = result.reduce((acc, curr) => { + // @ts-expect-error + const srvc = acc.find(s => s.serviceName === curr.serviceName) + + if (srvc === undefined) { + // @ts-expect-error + acc.push({ + serviceName: curr.serviceName, + permissions: [curr.permission], + }) + + return acc + } + + // @ts-expect-error + srvc.permissions.push(curr.permission) + + return acc + }, []) + + const services = parseServices(srv as Service[]) + + console.log(services) + const token = this.#createToken({ privKey, payload: { username, + services, }, expiresIn: expiry, }) @@ -113,7 +242,7 @@ export class JwtService { } public createRefreshCookie({ sessionId }: CreateRefreshCookieArgs) { - const { privKey, expiry} = this.configService.get('refreshKeys')! + const { privKey, expiry } = this.configService.get('refreshKeys')! const token = this.#createToken({ privKey, diff --git a/apps/backend/src/modules/protected/protected.controller.ts b/apps/backend/src/modules/protected/protected.controller.ts index 6cb3f88..5afc0a4 100644 --- a/apps/backend/src/modules/protected/protected.controller.ts +++ b/apps/backend/src/modules/protected/protected.controller.ts @@ -1,20 +1,60 @@ -import { Controller, Get, UseGuards } from '@nestjs/common' -import { AuthenticationGuard } from '../authentication/authentication.guard' +import { Controller, Get, Logger, Post, Req, UseGuards } from '@nestjs/common' import { Request as ExpressRequest } from 'express' -import { Req } from '@nestjs/common'; -import { SessionService } from '../session/session.service'; +import { AuthenticationGuard } from '../authentication/authentication.guard' +import { SessionService } from '../session/session.service' -@Controller('protected') +@Controller() export class ProtectedController { + readonly #logger = new Logger(ProtectedController.name) constructor( private readonly sessionService: SessionService, ) { } - // @UseGuards(AuthenticationGuard) - @Get() + @Get('protected') public getProtected(@Req() req: ExpressRequest) { - console.log(this.sessionService.meta.get(req)) + this.#logger.log(`${req.method} /protected route`) + return { success: true } + } + + @Post('protected') + public postProtected(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /protected route`) + return { success: true } + } + + @Get('books') + public getBooks(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /books route`) + return { success: true } + } + + @Post('books') + public postBooks(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /books route`) + return { success: true } + } + + @Get('books/hello') + public getBooksHello(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /books/hello route`) + return { success: true } + } + + @Post('books/hello') + public postBooksHello(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /books/hello route`) + return { success: true } + } + + @Get('random') + public getRandom(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /random route`) + return { success: true } + } + @Post('random') + public postRandom(@Req() req: ExpressRequest) { + this.#logger.log(`${req.method} /random route`) return { success: true } } } diff --git a/apps/backend/src/modules/session/session.middleware.ts b/apps/backend/src/modules/session/session.middleware.ts index 7972c82..d47d1a3 100644 --- a/apps/backend/src/modules/session/session.middleware.ts +++ b/apps/backend/src/modules/session/session.middleware.ts @@ -1,7 +1,7 @@ import { ForbiddenException, Injectable, Logger, NestMiddleware } from '@nestjs/common' import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from 'express' import { z } from 'zod' -import { JwtService } from '../jwt/jwt.service' +import { JwtService, ParsedServices } from '../jwt/jwt.service' import { SessionService } from './session.service' interface VerifyAccessTokenArgs { @@ -9,13 +9,32 @@ interface VerifyAccessTokenArgs { res: ExpressResponse } +// const permissionsSchema = z.record( +// z.string(), +// z.object({ +// permissions: z.array(z.enum(['read', 'change', 'delete'] as const)), +// z.record( +// z.string(), +// ) +// }) +// ) + // TODO(lwvemike): maybe reuse the part of the schema from signup const accessTokenSchema = z.object({ + // services: z.record(z.string(), z.array(z.string())), + services: z.record( + z.string(), + + ), username: z.string().min(3).max(64), + exp: z.number().int().positive(), + iat: z.number().int().positive(), }) const refreshTokenSchema = z.object({ sessionId: z.number().int().min(1), + exp: z.number().int().positive(), + iat: z.number().int().positive(), }) @Injectable() @@ -27,6 +46,9 @@ export class SessionMiddleware implements NestMiddleware { private readonly sessionService: SessionService, ) { } + /** + * @description To keep this example simple, will pass the request to session service that will set the meta + */ async use(req: ExpressRequest, res: ExpressResponse, next: NextFunction) { const accessToken: string | null = req.cookies?.[JwtService.ACCESS_TOKEN_NAME] ?? null const refreshToken: string | null = req.cookies?.[JwtService.REFRESH_TOKEN_NAME] ?? null @@ -44,7 +66,7 @@ export class SessionMiddleware implements NestMiddleware { await this.#handleSessionUpdate(refreshTokenData.sessionId) - const { name, value, options } = await this.sessionService.generateAccessToken(refreshTokenData.sessionId) + const { name, value, options } = await this.sessionService.generateAccessToken(refreshTokenData.sessionId, req) res.cookie(name, value, options) @@ -74,7 +96,7 @@ export class SessionMiddleware implements NestMiddleware { if (result.state === 'expired') { this.#logger.warn('Access token expired') - const { name, value, options } = await this.sessionService.generateAccessToken(refreshTokenData.sessionId) + const { name, value, options } = await this.sessionService.generateAccessToken(refreshTokenData.sessionId, req) res.cookie(name, value, options) this.#logger.log('New access token generated with the refresh token') @@ -106,15 +128,15 @@ export class SessionMiddleware implements NestMiddleware { return { state: 'expired', data: null } as const } - const result = accessTokenSchema.safeParse(decodedAccessToken.data) + // const result = accessTokenSchema.safeParse(decodedAccessToken.data) - if (result.success === false) { - this.#logger.error('Invalid access token after validation') + // if (result.success === false) { + // this.#logger.error('Invalid access token after validation') - throw new ForbiddenException('Invalid token') - } + // throw new ForbiddenException('Invalid token') + // } - return { state: 'valid', data: result.data } as const + return { state: 'valid', data: decodedAccessToken.data as ParsedServices } as const } #verifyRefreshToken({ token, res }: VerifyAccessTokenArgs) { diff --git a/apps/backend/src/modules/session/session.module.ts b/apps/backend/src/modules/session/session.module.ts index 419b289..5820a5f 100644 --- a/apps/backend/src/modules/session/session.module.ts +++ b/apps/backend/src/modules/session/session.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common' +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common' import { JwtModule } from '../jwt/jwt.module' import { SessionService } from './session.service' import { SessionMiddleware } from './session.middleware' @@ -8,4 +8,11 @@ import { SessionMiddleware } from './session.middleware' providers: [SessionService, SessionMiddleware], exports: [SessionService, SessionMiddleware], }) -export class SessionModule { } +export class SessionModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(SessionMiddleware) + .exclude(...SessionMiddleware.EXCLUDE_ROUTES) + .forRoutes('*') + } +} diff --git a/apps/backend/src/modules/session/session.service.ts b/apps/backend/src/modules/session/session.service.ts index d9dba70..c18ac06 100644 --- a/apps/backend/src/modules/session/session.service.ts +++ b/apps/backend/src/modules/session/session.service.ts @@ -2,13 +2,14 @@ import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' import { MySql2Database } from 'drizzle-orm/mysql2' import { eq } from 'drizzle-orm' import { z } from 'zod' +import { Request as ExpressRequest } from 'express' import * as schema from '../drizzle/schema' import { DATABASE_TAG } from '../drizzle/drizzle.module' -import { JwtService } from '../jwt/jwt.service' -import { Request as ExpressRequest } from 'express'; +import { JwtService, ParsedServices } from '../jwt/jwt.service' -type GetMetaReturn = { +interface GetMetaReturn { username: string + services: Record } interface CreateSessionArgs { @@ -23,7 +24,7 @@ interface CreateSessionArgs { export class SessionService { readonly #logger = new Logger(SessionService.name) - readonly #meta: WeakMap = new WeakMap() + readonly #meta: WeakMap = new WeakMap() constructor( @Inject(DATABASE_TAG) private readonly drizzle: MySql2Database, @@ -101,7 +102,7 @@ export class SessionService { .update(schema.session) .set({ lastAccessedAt: new Date() }) .where(eq(schema.session.id, sessionId)) - } + } // TODO(lwvemike): here the queries can be optimized, but also the schema should be changed public async getSessionUser(sessionId: number) { @@ -132,17 +133,27 @@ export class SessionService { return users[0] } - public async generateAccessToken(sessionId: number) { + public async generateAccessToken(sessionId: number, req: ExpressRequest) { const user = await this.getSessionUser(sessionId) - return this.jwtService.createAccessCookie({ username: user.username }) + // @ts-expect-error for future, I should fix it + this.setMeta(req, { username: user.username }) + + return this.jwtService.createAccessCookie({ username: user.username, roleId: user.roleId!, id: user.id }) } - public getMeta(req: ExpressRequest): GetMetaReturn | null { + public getMeta(req: ExpressRequest): ParsedServices | null { return this.#meta.get(req) ?? null } - public setMeta(req: ExpressRequest, data: GetMetaReturn) { + /** + * @throws {Error} + */ + public setMeta(req: ExpressRequest, data: ParsedServices) { + if (this.#meta.has(req)) { + throw new Error('Meta for this request already set') + } + this.#meta.set(req, data) } } diff --git a/apps/frontend/src/App.vue b/apps/frontend/src/App.vue index 368ef5d..e244aa3 100644 --- a/apps/frontend/src/App.vue +++ b/apps/frontend/src/App.vue @@ -14,15 +14,6 @@ const decodedToken = computed(() => { } }) -async function handleProtectedRouteCheck() { - await fetch('/api/protected', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) -} - const { signOut, isAuthenticated, user } = useAuth() @@ -60,12 +51,7 @@ const { signOut, isAuthenticated, user } = useAuth()

Cookie: [nfa-access-token]

{{ token }}

Decoded: [nfa-access-token]

-

{{ decodedToken }}

- -
- +
{{ JSON.stringify(decodedToken, null, 2) }}
diff --git a/apps/frontend/src/pages/home/components/AccessButtons.vue b/apps/frontend/src/pages/home/components/AccessButtons.vue new file mode 100644 index 0000000..a8aa1b2 --- /dev/null +++ b/apps/frontend/src/pages/home/components/AccessButtons.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/frontend/src/pages/home/index.vue b/apps/frontend/src/pages/home/index.vue index cbf3c4b..4e95ee4 100644 --- a/apps/frontend/src/pages/home/index.vue +++ b/apps/frontend/src/pages/home/index.vue @@ -1,5 +1,16 @@ + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcf53e3..ac3b77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,12 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 + lodash.groupby: + specifier: ^4.6.0 + version: 4.6.0 + lodash.set: + specifier: ^4.3.2 + version: 4.3.2 mysql2: specifier: ^3.10.0 version: 3.10.0 @@ -77,6 +83,12 @@ importers: '@types/jsonwebtoken': specifier: ^9.0.6 version: 9.0.6 + '@types/lodash.groupby': + specifier: ^4.6.9 + version: 4.6.9 + '@types/lodash.set': + specifier: ^4.3.9 + version: 4.3.9 '@types/node': specifier: ^20.3.1 version: 20.14.0 @@ -104,6 +116,9 @@ importers: eslint-plugin-prettier: specifier: ^5.0.0 version: 5.1.3(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.3.0) + esno: + specifier: ^4.7.0 + version: 4.7.0 jest: specifier: ^29.5.0 version: 29.7.0(@types/node@20.14.0)(ts-node@10.9.2) @@ -2293,6 +2308,22 @@ packages: '@types/node': 20.14.0 dev: true + /@types/lodash.groupby@4.6.9: + resolution: {integrity: sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ==} + dependencies: + '@types/lodash': 4.17.4 + dev: true + + /@types/lodash.set@4.3.9: + resolution: {integrity: sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==} + dependencies: + '@types/lodash': 4.17.4 + dev: true + + /@types/lodash@4.17.4: + resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==} + dev: true + /@types/mdast@3.0.15: resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} dependencies: @@ -4553,6 +4584,13 @@ packages: - supports-color dev: true + /esno@4.7.0: + resolution: {integrity: sha512-81owrjxIxOwqcABt20U09Wn8lpBo9K6ttqbGvQcB3VYNLJyaV1fvKkDtpZd3Rj5BX3WXiGiJCjUevKQGNICzJg==} + hasBin: true + dependencies: + tsx: 4.13.0 + dev: true + /espree@10.0.1: resolution: {integrity: sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6011,6 +6049,10 @@ packages: p-locate: 6.0.0 dev: true + /lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + dev: false + /lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} dev: false @@ -6047,6 +6089,10 @@ packages: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} dev: false + /lodash.set@4.3.2: + resolution: {integrity: sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==} + dev: false + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -7569,6 +7615,17 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /tsx@4.13.0: + resolution: {integrity: sha512-kNY70P2aLMdVBii1Err5ENxDhQ6Vz2PbQGX68DcvzY2/PWK5NLBO6vI7lPr1/2xG3IKSt2MN+KOAyWDQSRlbCA==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.20.2 + get-tsconfig: 4.7.5 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'}