Skip to content

Commit

Permalink
Add HasPermission decorator (#1)
Browse files Browse the repository at this point in the history
* Add has permission decorator

* Update README

* fixed lint and prettier

* Remove * permission, and accept authenticated empty
  • Loading branch information
psenderos authored Feb 26, 2024
1 parent 182ae06 commit b2f5320
Show file tree
Hide file tree
Showing 14 changed files with 202 additions and 19 deletions.
56 changes: 53 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(", ")}]`,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 16 additions & 0 deletions src/decorators/has-permission/has-permission.decorator.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
37 changes: 37 additions & 0 deletions src/decorators/has-permission/permissions.guard.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 5 additions & 0 deletions src/decorators/has-permission/permissions.types.ts
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion src/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -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";
1 change: 1 addition & 0 deletions src/utils/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface User {
tokenType?: string;
permissions?: string[];
}

export interface Request<U extends User = User> {
Expand Down
19 changes: 17 additions & 2 deletions tests/express-graphql.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,6 +30,9 @@ class Cat {

@Field(() => String)
name: string;

@Field(() => String)
nickname: string;
}

@Resolver(() => Cat)
Expand All @@ -37,6 +45,7 @@ class TestResolver {
{
tokenType: "dog",
uid: "corgi",
permissions: ["nickname.read"],
},
"secret",
);
Expand All @@ -63,6 +72,12 @@ class TestResolver {
async name() {
return "dog";
}

@ResolveField()
@HasPermission("nickname.read")
async nickname() {
return "fuffi";
}
}

let app: NestApplication;
Expand Down Expand Up @@ -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);

Expand Down
21 changes: 20 additions & 1 deletion tests/express-rest.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -18,6 +23,7 @@ class TestController {
{
tokenType: "dog",
uid: "corgi",
permissions: ["mouse.read"],
},
"secret",
);
Expand All @@ -33,6 +39,14 @@ class TestController {
};
}

@Get("/mouse")
@HasPermission("mouse.read")
async mouse() {
return {
hello: "squit",
};
}

@Post("/logout")
async logout(
@Req() request: Request,
Expand Down Expand Up @@ -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"))
Expand Down
19 changes: 17 additions & 2 deletions tests/fastify-graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,6 +32,9 @@ class Cat {

@Field(() => String)
name: string;

@Field(() => String)
nickname: string;
}

@Resolver(() => Cat)
Expand All @@ -39,6 +47,7 @@ class TestResolver {
{
tokenType: "dog",
uid: "corgi",
permissions: ["nickname.read"],
},
"secret",
);
Expand Down Expand Up @@ -68,6 +77,12 @@ class TestResolver {
async name() {
return "dog";
}

@ResolveField()
@HasPermission("nickname.read")
async nickname() {
return "fuffi";
}
}

let app: NestFastifyApplication;
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit b2f5320

Please sign in to comment.