diff --git a/src/Http/Scope/types.ts b/src/Http/Scope/types.ts index d16c432..a7f461a 100644 --- a/src/Http/Scope/types.ts +++ b/src/Http/Scope/types.ts @@ -1,10 +1,12 @@ import type { Context, DefaultState, Next, Request } from 'koa'; import { type HttpRequest } from '../Request'; import { type HttpResponse } from '../Response'; +import { type User } from '@/Security/types'; -export interface HttpScope extends Context { +export interface HttpScope extends Context { request: TRequest; response: HttpResponse; + user?: TUser; } export type NextMiddleware = Next; diff --git a/src/Security/firewall/firewall-middleware.test.ts b/src/Security/firewall/firewall-middleware.test.ts new file mode 100644 index 0000000..acf7378 --- /dev/null +++ b/src/Security/firewall/firewall-middleware.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, vi } from 'vitest'; +import { type HttpScope } from '@/Http'; +import { firewall } from '@/Security/firewall/firewall-middleware'; + +describe('Firewall Middleware', () => { + test('it should skip unmatched firewalls', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/public' }; + + await middleware({ request } as HttpScope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should skip unsecured firewall', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/public', + security: false, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/public' }; + + await middleware({ request } as HttpScope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should pass if user is already authenticated', async () => { + const userProvider = vi.fn(); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/admin' }; + const scope = { request, user: { identifier: 'user-1' } } as unknown as HttpScope; + + await middleware(scope, next); + + expect(userProvider).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('it should stop execution if authentication fails', async () => { + const userProvider = vi.fn().mockResolvedValue(undefined); + const config = { + firewalls: [ + { + pattern: '^/admin', + security: true, + provider: userProvider, + }, + ], + }; + const middleware = firewall(config); + const next = vi.fn(); + const request = { path: '/admin' }; + const internalThrow = vi.fn(); + const scope = { + request, + throw: internalThrow, + } as unknown as HttpScope; + + await middleware(scope, next); + + expect(userProvider).toHaveBeenCalled(); + expect(internalThrow).toHaveBeenCalledWith(401, 'Authentication required'); + }); +}); diff --git a/src/Security/firewall/firewall-middleware.ts b/src/Security/firewall/firewall-middleware.ts new file mode 100644 index 0000000..16dcc0f --- /dev/null +++ b/src/Security/firewall/firewall-middleware.ts @@ -0,0 +1,28 @@ +import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http'; +import { type Firewall, type SecurityConfig } from '@/Security/types'; + +export function firewall(config: SecurityConfig): HttpMiddleware { + return async function firewallMiddleware(scope: HttpScope, next: NextMiddleware): Promise { + const firewall = config.firewalls.find(fw => { + const regex = new RegExp(fw.pattern); + return regex.test(scope.request.path); + }); + + if (undefined !== firewall) { + await runFirewall(firewall, scope); + } + + await next(); + }; +} + +async function runFirewall(firewall: Firewall, scope: HttpScope): Promise { + if (!firewall.security) return; + if (undefined !== scope.user) return; + + const user = await firewall.provider?.(scope.request); + + if (undefined === user) scope.throw(401, 'Authentication required'); + + scope.user = user; +} diff --git a/src/Security/index.ts b/src/Security/index.ts new file mode 100644 index 0000000..01560c2 --- /dev/null +++ b/src/Security/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './firewall/firewall-middleware'; diff --git a/src/Security/types.ts b/src/Security/types.ts new file mode 100644 index 0000000..fe527cd --- /dev/null +++ b/src/Security/types.ts @@ -0,0 +1,17 @@ +import { type HttpRequest } from '@/Http'; + +export interface User { + roles?: string[]; +} + +export type UserProvider = (request: HttpRequest) => Promise; + +export interface Firewall { + pattern: string; + security: boolean; + provider?: UserProvider; +} + +export interface SecurityConfig { + firewalls: Firewall[]; +} diff --git a/src/Testing/TestAgentFactory.ts b/src/Testing/TestAgentFactory.ts index 3e708b6..d7bac54 100644 --- a/src/Testing/TestAgentFactory.ts +++ b/src/Testing/TestAgentFactory.ts @@ -2,7 +2,25 @@ import supertest from 'supertest'; import { type TestAgent } from './types'; import { create } from '@/Application'; import { type KoalaConfig } from '@/Config'; +import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http'; +import { type User } from '@/Security/types'; -export function createTestAgent(config: KoalaConfig): TestAgent { - return supertest(create(config).callback()); +export function createTestAgent(config: KoalaConfig, agentConfig?: { actAs?: User }): TestAgent { + const globalMiddleware = config.globalMiddleware ?? []; + const testConfig = { ...config }; + + const { actAs } = agentConfig ?? {}; + + if (undefined !== actAs) { + testConfig.globalMiddleware = [actAsUser(actAs), ...globalMiddleware]; + } + + return supertest(create(testConfig).callback()); +} + +function actAsUser(user: User): HttpMiddleware { + return async function actAsUserMiddleware(scope: HttpScope, next: NextMiddleware): Promise { + scope.user = user; + await next(); + }; } diff --git a/tests/security-authentication.e2e.test.ts b/tests/security-authentication.e2e.test.ts new file mode 100644 index 0000000..0c0d7c0 --- /dev/null +++ b/tests/security-authentication.e2e.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createTestAgent, type HttpScope, Route } from '../src'; +import { firewall } from '../src/Security/firewall/firewall-middleware'; + +class MyController { + @Route({ path: '/public', method: 'get' }) + publicResource(scope: HttpScope): void { + scope.response.body = 'Hello, World!'; + } + + @Route({ path: '/api/v1/protected', method: 'get' }) + protectedResource(scope: HttpScope): void { + scope.response.body = 'Protected Resource'; + } +} + +const userProvider = vi.fn(); +const securityConfig = { + firewalls: [ + { + pattern: '^/public', + security: false, + }, + { + pattern: '^/api', + security: true, + }, + { + pattern: '^/protected/with/provider', + security: true, + provider: userProvider, + }, + ], +}; + +describe('Security Authentication E2E Tests', () => { + test('Firewall should block access to protected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/api/v1/protected'); + + expect(response.status).toBe(401); + expect(response.text).toBe('Authentication required'); + }); + + test('Firewall should allow only authenticated users to access protected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const MyUser = { identifier: 'user-1' }; + const agent = createTestAgent(config, { actAs: MyUser }); + + const response = await agent.get('/api/v1/protected'); + + expect(response.status).toBe(200); + expect(response.text).toBe('Protected Resource'); + }); + + test('Firewall should allow access to unprotected route', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/public'); + + expect(response.status).toBe(200); + expect(response.text).toBe('Hello, World!'); + }); + + test('Firewall should depend on specified user provider for authentication', async () => { + userProvider.mockResolvedValue({ identifier: 'provided-user' }); + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + await agent.get('/protected/with/provider'); + + expect(userProvider).toHaveBeenCalled(); + }); + + test('Firewall should bypass routes without firewall', async () => { + const config = { + controllers: [MyController], + globalMiddleware: [firewall(securityConfig)], + }; + const agent = createTestAgent(config); + + const response = await agent.get('/no/firewall/here'); + + expect(response.status).toBe(404); + }); +});