diff --git a/README.md b/README.md index 083c4760..932f2ba4 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,49 @@ class UserDetailsController extends BaseHttpController { } ``` +### Decorators +There are helpers for AuthProvider these are wrapped into decorators these are applied to action methods. +Every decorator parameters correspond to method arguments with the equal name of the authenrication provider instance. +```ts +import { inRole, isAuthenticated, isResourceOwner } from 'inversify-express-utils'; + +@controller("/") +class TestController extends BaseHttpController { + + @httpGet("isAuthenticated") + @isAuthenticated() + public async isAuthenticated() { + return this.ok("OK"); + } + + @httpGet("isResourceOwner") + @isResourceOwner("2301") + public async isResourceOwner() { + return this.ok("OK"); + } + + @httpGet("inRole") + @inRole("admin") + public async inRole() { + return this.ok("OK"); + } +} +``` + +#### Custom HttpContext access decorator +if you need to define own access decorator that reuses the HttpContext reuse ```httpContextAccessDecoratorFactory``` +as it is in an example under. + +```ts +import { httpContextAccessDecoratorFactory } from 'inversify-express-utils'; + +function userExists (pass = true): any { + return httpContextAccessDecoratorFactory(async (context) => { + return (await context.user.isAuthenticated() && pass); + }); +} +``` + ## BaseMiddleware Extending `BaseMiddleware` allow us to inject dependencies diff --git a/src/decorators.ts b/src/decorators.ts index c52b891c..9e5d284d 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -1,7 +1,8 @@ -import * as express from "express"; import { inject, injectable, decorate } from "inversify"; import { interfaces } from "./interfaces"; import { TYPE, METADATA_KEY, PARAMETER_TYPE } from "./constants"; +import { Handler, NextFunction, Request, Response } from "express"; +import HttpContext = interfaces.HttpContext; export const injectHttpContext = inject(TYPE.HttpContext); @@ -130,3 +131,50 @@ export function params(type: PARAMETER_TYPE, parameterName?: string) { Reflect.defineMetadata(METADATA_KEY.controllerParameter, metadataList, target.constructor); }; } + +export interface IContextAcessAdapterFn { + (context: HttpContext): Promise; +} + +export function httpContextAccessDecoratorFactory(implementation: IContextAcessAdapterFn) { + return function (target: any, key: string | symbol, descriptor: TypedPropertyDescriptor) { + const fn = descriptor.value as Handler; + descriptor.value = async function (_request: Request, _response: Response, _next: NextFunction) { + const context: HttpContext = Reflect.getMetadata( + METADATA_KEY.httpContext, + _request + ); + const hasAccess = await implementation(context); + if (hasAccess) { + return fn.call(this, _request, _response); + } else { + _response + .status(403) + .send({ error: "The user is not authenticated." }); + + return _response; + } + }; + + return descriptor; + }; +} + +export function isAuthenticated (pass = true): any { + return httpContextAccessDecoratorFactory(async (context) => { + return (await context.user.isAuthenticated() && pass); + }); +} + +export function inRole (role: string, pass = true): any { + return httpContextAccessDecoratorFactory(async (context) => { + return (await context.user.isInRole(role) && pass); + }); +} + +export function isResourceOwner (resorceId: string, pass = true): any { + return httpContextAccessDecoratorFactory(async (context) => { + return (await context.user.isResourceOwner(resorceId) && pass); + }); +} + diff --git a/src/index.ts b/src/index.ts index 766d6548..d2d67a73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import { InversifyExpressServer } from "./server"; import { controller, httpMethod, httpGet, httpPut, httpPost, httpPatch, httpHead, all, httpDelete, request, response, requestParam, queryParam, - requestBody, requestHeaders, cookies, next, principal, injectHttpContext } from "./decorators"; + requestBody, requestHeaders, cookies, next, principal, injectHttpContext, + httpContextAccessDecoratorFactory, isAuthenticated, isResourceOwner, inRole } from "./decorators"; import { TYPE } from "./constants"; import { interfaces } from "./interfaces"; import * as results from "./results"; @@ -14,6 +15,7 @@ import { StringContent } from "./content/stringContent"; import { JsonContent } from "./content/jsonContent"; import { HttpContent } from "./content/httpContent"; + export { getRouteInfo, getRawMetadata, @@ -46,5 +48,9 @@ export { HttpContent, StringContent, JsonContent, - results + results, + httpContextAccessDecoratorFactory, + inRole, + isAuthenticated, + isResourceOwner }; diff --git a/test/auth_provider.test.ts b/test/auth_provider.test.ts index f5a1bccd..e5e3e573 100644 --- a/test/auth_provider.test.ts +++ b/test/auth_provider.test.ts @@ -11,6 +11,8 @@ import { interfaces } from "../src/index"; import { cleanUpMetadata } from "../src/utils"; +import {httpContextAccessDecoratorFactory, inRole, isAuthenticated, isResourceOwner} from "../src/decorators"; +import AuthProvider = interfaces.AuthProvider; describe("AuthProvider", () => { @@ -70,8 +72,8 @@ describe("AuthProvider", () => { if (this.httpContext.user !== null) { const email = this.httpContext.user.details.email; const name = this._someDependency.name; - const isAuthenticated = await this.httpContext.user.isAuthenticated(); - expect(isAuthenticated).eq(true); + const _isAuthenticated = await this.httpContext.user.isAuthenticated(); + expect(_isAuthenticated).eq(true); return `${email} & ${name}`; } } @@ -96,4 +98,191 @@ describe("AuthProvider", () => { }); + describe("Principal`s decorators", () => { + class Principal implements interfaces.Principal { + public details: any; + public constructor(details: any) { + this.details = details; + } + public isAuthenticated() { + return Promise.resolve(!!this.details._id); + } + public isResourceOwner(resourceId: any) { + return Promise.resolve(this.details.resourceIds.indexOf(resourceId) > -1); + } + public isInRole(role: string) { + return Promise.resolve(this.details.roles.indexOf(role) > -1); + } + } + + interface IUserDetails { + _id: number; + resourceIds?: string[]; + roles?: string[]; + } + + @injectable() + class CustomAuthProvider implements interfaces.AuthProvider { + + @inject("UserDetails") private readonly _details: IUserDetails; + + public getUser( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) { + const principal = new Principal(this._details); + return Promise.resolve(principal); + } + } + + function userExists (pass = true): any { + return httpContextAccessDecoratorFactory(async (context) => { + return (await context.user.isAuthenticated() && pass); + }); + } + + @controller("/") + class TestController extends BaseHttpController { + @httpGet("userExists") + @userExists() + public async userExists() { + return this.ok("OK"); + } + + @httpGet("isAuthenticated") + @isAuthenticated() + public async isAuthenticated() { + return this.ok("OK"); + } + + @httpGet("isResourceOwner") + @isResourceOwner("2301") + public async isResourceOwner() { + return this.ok("OK"); + } + + @httpGet("inRole") + @inRole("admin") + public async inRole() { + return this.ok("OK"); + } + } + + const container = new Container(); + container.bind("UserDetails") + .toConstantValue({ _id: 1 }); + + + const server = new InversifyExpressServer( + container, + null, + null, + null, + CustomAuthProvider + ); + const app = server.build(); + const authProvider = container.get(TYPE.AuthProvider); + + describe("Custom decorator", () => { + it("Rejected", (done) => { + container.rebind("UserDetails") + .toConstantValue({ _id: 0}); + expect(true).eq(true); + + supertest(app) + .get("/userExists") + .expect(403) + .end(done); + }); + + it("Accepted", (done) => { + container.rebind("UserDetails") + .toConstantValue({ _id: 1}); + expect(true).eq(true); + + supertest(app) + .get("/userExists") + .expect(200, `"OK"`, done); + }); + }); + + describe("IsAuthenticated", () => { + it("Rejected", (done) => { + container.rebind("UserDetails") + .toConstantValue({ _id: 0}); + expect(true).eq(true); + + supertest(app) + .get("/isAuthenticated") + .expect(403) + .end(done); + }); + + it("Accepted", (done) => { + container.rebind("UserDetails") + .toConstantValue({ _id: 1}); + expect(true).eq(true); + + supertest(app) + .get("/isAuthenticated") + .expect(200, `"OK"`, done); + }); + }); + + describe("IsResorceOwner", () => { + it("Rejected", (done) => { + container.rebind("UserDetails").toConstantValue({ + _id: 1, + resourceIds: ["2909"] + }); + expect(true).eq(true); + + supertest(app) + .get("/isResourceOwner") + .expect(403) + .end(done); + }); + + it("Accepted", (done) => { + container.rebind("UserDetails").toConstantValue({ + _id: 1, + resourceIds: ["2301"] + }); + expect(true).eq(true); + + supertest(app) + .get("/isResourceOwner") + .expect(200, `"OK"`, done); + }); + }); + + describe("IsRole", () => { + it("Rejected", (done) => { + container.rebind("UserDetails").toConstantValue({ + _id: 1, + roles: ["manager"] + }); + expect(true).eq(true); + + supertest(app) + .get("/inRole") + .expect(403) + .end(done); + }); + + it("Accepted", (done) => { + container.rebind("UserDetails").toConstantValue({ + _id: 1, + roles: ["admin"] + }); + expect(true).eq(true); + + supertest(app) + .get("/inRole") + .expect(200, `"OK"`, done); + }); + }); + }); + });