From b2f5320d6364e0553fa3640befef75f09cab0835 Mon Sep 17 00:00:00 2001 From: Furio Dipoppa Date: Mon, 26 Feb 2024 15:28:20 +0100 Subject: [PATCH] Add HasPermission decorator (#1) * Add has permission decorator * Update README * fixed lint and prettier * Remove * permission, and accept authenticated empty --- README.md | 56 ++++++++++++++++++- src/constants/index.ts | 1 + .../authenticated.decorator.ts | 2 +- .../{ => authenticated}/token-type.guard.ts | 16 +++--- .../token-types.decorator.ts | 2 +- .../has-permission.decorator.ts | 16 ++++++ .../has-permission/permissions.guard.ts | 37 ++++++++++++ .../has-permission/permissions.types.ts | 5 ++ src/decorators/index.ts | 3 +- src/utils/types.ts | 1 + tests/express-graphql.test.ts | 19 ++++++- tests/express-rest.test.ts | 21 ++++++- tests/fastify-graphql.test.ts | 19 ++++++- tests/fastify-rest.test.ts | 23 +++++++- 14 files changed, 202 insertions(+), 19 deletions(-) rename src/decorators/{ => authenticated}/authenticated.decorator.ts (91%) rename src/decorators/{ => authenticated}/token-type.guard.ts (67%) rename src/decorators/{ => authenticated}/token-types.decorator.ts (72%) create mode 100644 src/decorators/has-permission/has-permission.decorator.ts create mode 100644 src/decorators/has-permission/permissions.guard.ts create mode 100644 src/decorators/has-permission/permissions.types.ts diff --git a/README.md b/README.md index d4e2826..1d23cb0 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,14 @@ custom transformations on the parsed access token. ## Authenticating users -You can authenticate users based on their role (or token type). -The library assumes that all access tokens contain a `tokenType` field. +You can authenticate users based on their role (or token type) or based on the permission. +The library assumes that all access tokens contain a `tokenType` field or a `permissions` array. Authentication can be applied on the class level or on the method level. +### @Authenticated + +The library will check that the token type is equal with one of the roles declared in the decorator + ```typescript import { Authenticated } from "@moveaxlab/nestjs-security"; @@ -94,6 +98,40 @@ class MyController { } ``` +In order check that the user has a valid accessToken, but without any required permission or roles you can use the `@Authenticated` decorator without any tokenType. + +```typescript +import { HasPermission } from "@moveaxlab/nestjs-security"; +import { Authenticated } from "./authenticated.decorator"; + +@Authenticated() +class MyController { + async getMyProfile() { + // only accessibile to authenticated user + } +} +``` + +### @HasPermission + +The library will search for the required permission in the `permissions` array. + +```typescript +import { HasPermission } from "@moveaxlab/nestjs-security"; + +@HasPermission("myResource.read") +class MyController { + async firstMethod() { + // accessible to token with permission myResource.read + } + + @HasPermission("myResource.write") + async secondMethod() { + // only accessible to token with the permissions myResourse.write + } +} +``` + ## Setting cookies Use the `CookieService` to set and unset the access token and refresh token. @@ -191,11 +229,16 @@ You can access the parsed access token and refresh token inside your controllers and resolvers using decorators. ```typescript -import { Authenticated, AccessToken } from "@moveaxlab/nestjs-security"; +import { + Authenticated, + AccessToken, + HasPermission, +} from "@moveaxlab/nestjs-security"; interface User { tokenType: "admin" | "user"; uid: string; + permission: string[]; // other information contained in the token } @@ -205,6 +248,13 @@ class MyController { // use the token here } } + +@HasPermission("myPermission") +class MySecondController { + async mySecondMethod(@AccessToken() token: User) { + // use the token here + } +} ``` The refresh token can be accessed via decorators when using cookies. diff --git a/src/constants/index.ts b/src/constants/index.ts index 249b824..1fbf629 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -3,3 +3,4 @@ export const SECURITY_CONFIG_INJECTION_KEY = "@moveax/security-config"; export const REDIS_INJECTION_KEY = "@moveax/security-redis"; export const TOKEN_TYPES_METADATA_KEY = "@moveax/token-type-metadata-key"; +export const PERMISSIONS_METADATA_KEY = "@moveax/permission-metadata-key"; diff --git a/src/decorators/authenticated.decorator.ts b/src/decorators/authenticated/authenticated.decorator.ts similarity index 91% rename from src/decorators/authenticated.decorator.ts rename to src/decorators/authenticated/authenticated.decorator.ts index 531ff91..4082a9d 100644 --- a/src/decorators/authenticated.decorator.ts +++ b/src/decorators/authenticated/authenticated.decorator.ts @@ -1,6 +1,6 @@ import { applyDecorators, UseGuards } from "@nestjs/common"; import { TokenTypes } from "./token-types.decorator"; -import { AuthGuard } from "./auth.guard"; +import { AuthGuard } from "../auth.guard"; import { TokenTypeGuard } from "./token-type.guard"; /** diff --git a/src/decorators/token-type.guard.ts b/src/decorators/authenticated/token-type.guard.ts similarity index 67% rename from src/decorators/token-type.guard.ts rename to src/decorators/authenticated/token-type.guard.ts index 31410ac..8b5fab1 100644 --- a/src/decorators/token-type.guard.ts +++ b/src/decorators/authenticated/token-type.guard.ts @@ -6,8 +6,8 @@ import { Dependencies, } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; -import { getRequest } from "../utils"; -import { TOKEN_TYPES_METADATA_KEY } from "../constants"; +import { getRequest } from "../../utils"; +import { TOKEN_TYPES_METADATA_KEY } from "../../constants"; @Dependencies(Reflector) @Injectable() @@ -26,12 +26,14 @@ export class TokenTypeGuard implements CanActivate { const allowedTokenTypes = (this.reflector.get( TOKEN_TYPES_METADATA_KEY, context.getHandler(), - ) || - this.reflector.get( - TOKEN_TYPES_METADATA_KEY, - context.getClass(), - )) as string[]; + ) || this.reflector.get(TOKEN_TYPES_METADATA_KEY, context.getClass())) as + | string[] + | undefined; + if (!allowedTokenTypes || allowedTokenTypes.length === 0) { + this.logger.debug(`No allowed token type specified, continue`); + return true; + } this.logger.debug( `Allowed token types are: [${allowedTokenTypes.join(", ")}]`, ); diff --git a/src/decorators/token-types.decorator.ts b/src/decorators/authenticated/token-types.decorator.ts similarity index 72% rename from src/decorators/token-types.decorator.ts rename to src/decorators/authenticated/token-types.decorator.ts index 58a4a07..fc79d1b 100644 --- a/src/decorators/token-types.decorator.ts +++ b/src/decorators/authenticated/token-types.decorator.ts @@ -1,5 +1,5 @@ import { SetMetadata } from "@nestjs/common"; -import { TOKEN_TYPES_METADATA_KEY } from "../constants"; +import { TOKEN_TYPES_METADATA_KEY } from "../../constants"; export const TokenTypes = (...tokenTypes: string[]) => SetMetadata(TOKEN_TYPES_METADATA_KEY, tokenTypes); diff --git a/src/decorators/has-permission/has-permission.decorator.ts b/src/decorators/has-permission/has-permission.decorator.ts new file mode 100644 index 0000000..d36cb5f --- /dev/null +++ b/src/decorators/has-permission/has-permission.decorator.ts @@ -0,0 +1,16 @@ +import { applyDecorators, UseGuards } from "@nestjs/common"; +import { PermissionsGuard } from "./permissions.guard"; +import { PermissionsTypes } from "./permissions.types"; +import { AuthGuard } from "../auth.guard"; + +/** + * Allows only requests from users with a token in the given types. + * + * @param permission list of allowed token types + */ +export function HasPermission(permission: string) { + return applyDecorators( + UseGuards(AuthGuard, PermissionsGuard), + PermissionsTypes(permission), + ); +} diff --git a/src/decorators/has-permission/permissions.guard.ts b/src/decorators/has-permission/permissions.guard.ts new file mode 100644 index 0000000..dfa26de --- /dev/null +++ b/src/decorators/has-permission/permissions.guard.ts @@ -0,0 +1,37 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + Dependencies, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { getRequest } from "../../utils"; +import { PERMISSIONS_METADATA_KEY } from "../../constants"; + +@Dependencies(Reflector) +@Injectable() +export class PermissionsGuard implements CanActivate { + private readonly logger = new Logger(PermissionsGuard.name); + + constructor(private readonly reflector: Reflector) {} + + async canActivate(context: ExecutionContext) { + const user = getRequest(context).user; + + if (!user?.permissions) { + return false; + } + + const permission = (this.reflector.get( + PERMISSIONS_METADATA_KEY, + context.getHandler(), + ) || + this.reflector.get( + PERMISSIONS_METADATA_KEY, + context.getClass(), + )) as string; + + return user.permissions.includes(permission); + } +} diff --git a/src/decorators/has-permission/permissions.types.ts b/src/decorators/has-permission/permissions.types.ts new file mode 100644 index 0000000..ea3db01 --- /dev/null +++ b/src/decorators/has-permission/permissions.types.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; +import { PERMISSIONS_METADATA_KEY } from "../../constants"; + +export const PermissionsTypes = (permission: string) => + SetMetadata(PERMISSIONS_METADATA_KEY, permission); diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 23ace8b..786597c 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1,4 +1,5 @@ export { AccessToken } from "./access-token.decorator"; -export { Authenticated } from "./authenticated.decorator"; +export { Authenticated } from "./authenticated/authenticated.decorator"; +export { HasPermission } from "./has-permission/has-permission.decorator"; export { RefreshCookieInterceptor } from "./refresh-cookie.interceptor"; export { RefreshToken } from "./refresh-token.decorator"; diff --git a/src/utils/types.ts b/src/utils/types.ts index 055ab96..283285f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,5 +1,6 @@ export interface User { tokenType?: string; + permissions?: string[]; } export interface Request { diff --git a/tests/express-graphql.test.ts b/tests/express-graphql.test.ts index 9406a6b..1e15fd6 100644 --- a/tests/express-graphql.test.ts +++ b/tests/express-graphql.test.ts @@ -1,6 +1,11 @@ import { Test } from "@nestjs/testing"; import request from "supertest"; -import { Authenticated, CookieService, SecurityModule } from "../src"; +import { + Authenticated, + CookieService, + HasPermission, + SecurityModule, +} from "../src"; import { sign } from "jsonwebtoken"; import { Resolver, @@ -25,6 +30,9 @@ class Cat { @Field(() => String) name: string; + + @Field(() => String) + nickname: string; } @Resolver(() => Cat) @@ -37,6 +45,7 @@ class TestResolver { { tokenType: "dog", uid: "corgi", + permissions: ["nickname.read"], }, "secret", ); @@ -63,6 +72,12 @@ class TestResolver { async name() { return "dog"; } + + @ResolveField() + @HasPermission("nickname.read") + async nickname() { + return "fuffi"; + } } let app: NestApplication; @@ -114,7 +129,7 @@ it(`performs login, query, and logout`, async () => { .post("/graphql") .set("Cookie", cookies) .send({ - query: `{ cats { hello name } }`, + query: `{ cats { hello name nickname } }`, }); expect(queryResult.statusCode).toEqual(200); diff --git a/tests/express-rest.test.ts b/tests/express-rest.test.ts index f4b0c1f..27fe9a7 100644 --- a/tests/express-rest.test.ts +++ b/tests/express-rest.test.ts @@ -1,6 +1,11 @@ import { Test } from "@nestjs/testing"; import request from "supertest"; -import { Authenticated, CookieService, SecurityModule } from "../src"; +import { + Authenticated, + CookieService, + HasPermission, + SecurityModule, +} from "../src"; import { Controller, Get, Post, Req, Res } from "@nestjs/common"; import { sign } from "jsonwebtoken"; import { parseExpressCookies } from "./utils"; @@ -18,6 +23,7 @@ class TestController { { tokenType: "dog", uid: "corgi", + permissions: ["mouse.read"], }, "secret", ); @@ -33,6 +39,14 @@ class TestController { }; } + @Get("/mouse") + @HasPermission("mouse.read") + async mouse() { + return { + hello: "squit", + }; + } + @Post("/logout") async logout( @Req() request: Request, @@ -77,6 +91,11 @@ it(`performs login, query, and logout`, async () => { .set("Cookie", loginResult.get("Set-Cookie")); expect(queryResult.statusCode).toEqual(200); + const permissionQueryResult = await request(app.getHttpServer()) + .get("/mouse") + .set("Cookie", loginResult.get("Set-Cookie")); + expect(permissionQueryResult.statusCode).toEqual(200); + const logoutResult = await request(app.getHttpServer()) .post("/logout") .set("Cookie", loginResult.get("Set-cookie")) diff --git a/tests/fastify-graphql.test.ts b/tests/fastify-graphql.test.ts index 0a19362..020bbbd 100644 --- a/tests/fastify-graphql.test.ts +++ b/tests/fastify-graphql.test.ts @@ -3,7 +3,12 @@ import { FastifyAdapter, NestFastifyApplication, } from "@nestjs/platform-fastify"; -import { Authenticated, CookieService, SecurityModule } from "../src"; +import { + Authenticated, + CookieService, + HasPermission, + SecurityModule, +} from "../src"; import { FastifyRequest, FastifyReply } from "fastify"; import { sign } from "jsonwebtoken"; import fastifyCookie from "@fastify/cookie"; @@ -27,6 +32,9 @@ class Cat { @Field(() => String) name: string; + + @Field(() => String) + nickname: string; } @Resolver(() => Cat) @@ -39,6 +47,7 @@ class TestResolver { { tokenType: "dog", uid: "corgi", + permissions: ["nickname.read"], }, "secret", ); @@ -68,6 +77,12 @@ class TestResolver { async name() { return "dog"; } + + @ResolveField() + @HasPermission("nickname.read") + async nickname() { + return "fuffi"; + } } let app: NestFastifyApplication; @@ -124,7 +139,7 @@ it(`performs login, query, and logout`, async () => { const queryResult = await app.inject({ method: "POST", url: "/graphql", - body: { query: `{ cats { hello name } }` }, + body: { query: `{ cats { hello name nickname } }` }, cookies, }); expect(queryResult.statusCode).toEqual(200); diff --git a/tests/fastify-rest.test.ts b/tests/fastify-rest.test.ts index e09f8e4..a6211f4 100644 --- a/tests/fastify-rest.test.ts +++ b/tests/fastify-rest.test.ts @@ -3,7 +3,12 @@ import { FastifyAdapter, NestFastifyApplication, } from "@nestjs/platform-fastify"; -import { Authenticated, CookieService, SecurityModule } from "../src"; +import { + Authenticated, + CookieService, + HasPermission, + SecurityModule, +} from "../src"; import { Controller, Get, Post, Req, Res } from "@nestjs/common"; import { FastifyReply, FastifyRequest } from "fastify"; import { sign } from "jsonwebtoken"; @@ -20,6 +25,7 @@ class TestController { { tokenType: "dog", uid: "corgi", + permissions: ["dogs.read"], }, "secret", ); @@ -35,6 +41,14 @@ class TestController { }; } + @Get("/dogs") + @HasPermission("dogs.read") + async dogs() { + return { + hello: "world", + }; + } + @Post("/logout") async logout( @Req() request: FastifyRequest, @@ -88,6 +102,13 @@ it(`performs login, query, and logout`, async () => { }); expect(queryResult.statusCode).toEqual(200); + const queryResultDogs = await app.inject({ + method: "GET", + url: "/dogs", + cookies, + }); + expect(queryResultDogs.statusCode).toEqual(200); + const logoutResult = await app.inject({ method: "POST", url: "/logout",