diff --git a/config/default.cjs b/config/default.cjs index 19937da3..f4e82395 100644 --- a/config/default.cjs +++ b/config/default.cjs @@ -47,7 +47,8 @@ module.exports = { fetchSocketsCacheTTL: 1000, }, measurement: { - rateLimit: 100000, + anonymousRateLimit: 100000, + authenticatedRateLimit: 250, rateLimitReset: 3600, maxInProgressProbes: 5, // Timeout after which measurement will be marked as finished even if not all probes respond diff --git a/migrations/create-tables.js.sql b/migrations/create-tables.js.sql index 9b4df9c5..34f71f5e 100644 --- a/migrations/create-tables.js.sql +++ b/migrations/create-tables.js.sql @@ -1,3 +1,8 @@ +CREATE TABLE IF NOT EXISTS directus_users ( + id CHAR(36), + github_username VARCHAR(255) +); + CREATE TABLE IF NOT EXISTS gp_adopted_probes ( id INT(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_created CHAR(36), @@ -29,3 +34,13 @@ CREATE TABLE IF NOT EXISTS directus_notifications ( subject VARCHAR(255), message TEXT ); + +CREATE TABLE IF NOT EXISTS gp_tokens ( + id INT(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_created CHAR(36), + name VARCHAR(255), + value VARCHAR(255), + origins LONGTEXT, + expire DATE, + date_last_used DATE +); diff --git a/package-lock.json b/package-lock.json index 7f527f42..151e502f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "api", "version": "1.0.0", "dependencies": { + "@isaacs/ttlcache": "^1.4.1", "@koa/router": "^12.0.1", "@maxmind/geoip2-node": "^4.2.0", "@redocly/openapi-core": "^1.6.0", @@ -3593,6 +3594,14 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "engines": { + "node": ">=12" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", diff --git a/package.json b/package.json index 5e070c6e..d873cbc5 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "dist/src/index.js", "dependencies": { + "@isaacs/ttlcache": "^1.4.1", "@koa/router": "^12.0.1", "@maxmind/geoip2-node": "^4.2.0", "@redocly/openapi-core": "^1.6.0", diff --git a/public/v1/spec.yaml b/public/v1/spec.yaml index 83830ce3..bce2f048 100644 --- a/public/v1/spec.yaml +++ b/public/v1/spec.yaml @@ -75,6 +75,9 @@ paths: $ref: 'components/responses.yaml#/components/responses/measurements429' tags: - Measurements + security: + - {} + - bearerAuth: [] /v1/measurements/{id}: parameters: - $ref: 'components/parameters.yaml#/components/parameters/measurementId' @@ -110,3 +113,8 @@ paths: $ref: 'components/responses.yaml#/components/responses/probes200' tags: - Probes +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer diff --git a/src/lib/adopted-probes.ts b/src/lib/adopted-probes.ts index d80b4a39..2ca309f4 100644 --- a/src/lib/adopted-probes.ts +++ b/src/lib/adopted-probes.ts @@ -142,7 +142,7 @@ export class AdoptedProbes { } private async fetchAdoptedProbes () { - const rows = await this.sql({ probes: ADOPTED_PROBES_TABLE }).select(); + const rows = await this.sql(ADOPTED_PROBES_TABLE).select(); const adoptedProbes: AdoptedProbe[] = rows.map(row => ({ diff --git a/src/lib/http/auth.ts b/src/lib/http/auth.ts new file mode 100644 index 00000000..adb7d57f --- /dev/null +++ b/src/lib/http/auth.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import type { Knex } from 'knex'; +import TTLCache from '@isaacs/ttlcache'; +import { scopedLogger } from '../logger.js'; +import { client } from '../sql/client.js'; + +export const GP_TOKENS_TABLE = 'gp_tokens'; +export const USERS_TABLE = 'directus_users'; + +const logger = scopedLogger('auth'); + +const TOKEN_TTL = 2 * 60 * 1000; + +export type Token = { + user_created: string, + value: string, + expire: Date | null, + origins: string[], + date_last_used: Date | null +} + +type Row = Omit & { + origins: string | null, +} + +export class Auth { + private validTokens = new TTLCache({ ttl: TOKEN_TTL }); + private invalidTokens = new TTLCache({ ttl: TOKEN_TTL }); + constructor (private readonly sql: Knex) {} + + scheduleSync () { + setTimeout(() => { + this.syncTokens() + .finally(() => this.scheduleSync()) + .catch(error => logger.error(error)); + }, 60_000); + } + + async syncTokens () { + const tokens = await this.fetchTokens(); + const newValidTokens = new TTLCache({ ttl: TOKEN_TTL }); + const newInvalidTokens = new TTLCache({ ttl: TOKEN_TTL }); + + tokens.forEach((token) => { + if (token.expire && this.isExpired(token.expire)) { + newInvalidTokens.set(token.value, true); + } else { + newValidTokens.set(token.value, token); + } + }); + + this.validTokens = newValidTokens; + this.invalidTokens = newInvalidTokens; + } + + async syncSpecificToken (hash: string) { + const tokens = await this.fetchTokens({ value: hash }); + + if (tokens.length === 0) { + this.invalidTokens.set(hash, true); + return undefined; + } + + const token = tokens[0]!; + + if (token.expire && this.isExpired(token.expire)) { + this.invalidTokens.set(hash, true); + return undefined; + } + + this.validTokens.set(hash, token); + return token; + } + + async fetchTokens (filter: Partial = {}) { + const rows = await this.sql(GP_TOKENS_TABLE).where(filter) + .select([ 'user_created', 'value', 'expire', 'origins', 'date_last_used' ]); + + const tokens: Token[] = rows.map(row => ({ + ...row, + origins: (row.origins ? JSON.parse(row.origins) as string[] : []), + })); + + return tokens; + } + + async validate (tokenString: string, origin: string) { + const bytes = Buffer.from(tokenString, 'base64'); + const hash = createHash('sha256').update(bytes).digest('base64'); + + if (this.invalidTokens.get(hash)) { + return null; + } + + let token = this.validTokens.get(hash); + + if (!token) { + token = await this.syncSpecificToken(hash); + } + + if (!token) { + return null; + } + + if (!this.isValidOrigin(origin, token.origins)) { + return null; + } + + await this.updateLastUsedDate(token); + return token.user_created; + } + + private async updateLastUsedDate (token: Token) { + if (!token.date_last_used || !this.isToday(token.date_last_used)) { + const date = new Date(); + await this.sql(GP_TOKENS_TABLE).where({ value: token.value }).update({ date_last_used: date }); + token.date_last_used = date; + } + } + + private isExpired (date: Date) { + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + return date < currentDate; + } + + private isValidOrigin (origin: string, validOrigins: string[]) { + return validOrigins.length > 0 ? validOrigins.includes(origin) : true; + } + + private isToday (date: Date) { + const currentDate = new Date(); + return date.toDateString() === currentDate.toDateString(); + } +} + +export const auth = new Auth(client); diff --git a/src/lib/http/middleware/authenticate.ts b/src/lib/http/middleware/authenticate.ts new file mode 100644 index 00000000..19fcd267 --- /dev/null +++ b/src/lib/http/middleware/authenticate.ts @@ -0,0 +1,30 @@ +import { auth } from '../auth.js'; +import type { ExtendedMiddleware } from '../../../types.js'; + +export const authenticate: ExtendedMiddleware = async (ctx, next) => { + const { headers } = ctx.request; + + if (headers && headers.authorization) { + const parts = headers.authorization.split(' '); + + if (parts.length !== 2 || parts[0] !== 'Bearer') { + ctx.status = 401; + return; + } + + const token = parts[1]!; + const origin = ctx.get('Origin'); + const userId = await auth.validate(token, origin); + + if (!userId) { + ctx.status = 401; + return; + } + + ctx.state.userId = userId; + } + + return next(); +}; + +export type AuthenticateState = { userId?: string }; diff --git a/src/lib/http/middleware/cors.ts b/src/lib/http/middleware/cors.ts index 78de7b3e..bcf22b99 100644 --- a/src/lib/http/middleware/cors.ts +++ b/src/lib/http/middleware/cors.ts @@ -2,11 +2,18 @@ import type { Context, Next } from 'koa'; export const corsHandler = () => async (ctx: Context, next: Next) => { ctx.set('Access-Control-Allow-Origin', '*'); - ctx.set('Access-Control-Allow-Headers', '*, Authorization'); + ctx.set('Access-Control-Allow-Headers', '*'); ctx.set('Access-Control-Expose-Headers', '*'); + ctx.set('Access-Control-Max-Age', '600'); ctx.set('Cross-Origin-Resource-Policy', 'cross-origin'); ctx.set('Timing-Allow-Origin', '*'); ctx.set('Vary', 'Accept-Encoding'); return next(); }; + +export const corsAuthHandler = () => async (ctx: Context, next: Next) => { + ctx.set('Access-Control-Allow-Headers', '*, Authorization'); + + return next(); +}; diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts new file mode 100644 index 00000000..deabd8b5 --- /dev/null +++ b/src/lib/rate-limiter.ts @@ -0,0 +1,59 @@ +import config from 'config'; +import type { Context } from 'koa'; +import { RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'; +import requestIp from 'request-ip'; +import { createPersistentRedisClient } from './redis/persistent-client.js'; +import createHttpError from 'http-errors'; +import type { ExtendedContext } from '../types.js'; + +const redisClient = await createPersistentRedisClient({ legacyMode: true }); + +export const anonymousRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'rate:anon', + points: config.get('measurement.anonymousRateLimit'), + duration: config.get('measurement.rateLimitReset'), +}); + +export const authenticatedRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'rate:auth', + points: config.get('measurement.authenticatedRateLimit'), + duration: config.get('measurement.rateLimitReset'), +}); + +export const rateLimit = async (ctx: ExtendedContext, numberOfProbes: number) => { + if (ctx['isAdmin']) { + return; + } + + let rateLimiter: RateLimiterRedis; + let id: string; + + if (ctx.state.userId) { + rateLimiter = authenticatedRateLimiter; + id = ctx.state.userId; + } else { + rateLimiter = anonymousRateLimiter; + id = requestIp.getClientIp(ctx.req) ?? ''; + } + + try { + const result = await rateLimiter.consume(id, numberOfProbes); + setRateLimitHeaders(ctx, result, rateLimiter); + } catch (error) { + if (error instanceof RateLimiterRes) { + const result = await rateLimiter.reward(id, numberOfProbes); + setRateLimitHeaders(ctx, result, rateLimiter); + throw createHttpError(429, 'Too Many Probes Requested', { type: 'too_many_probes' }); + } + + throw createHttpError(500); + } +}; + +const setRateLimitHeaders = (ctx: Context, result: RateLimiterRes, rateLimiter: RateLimiterRedis) => { + ctx.set('X-RateLimit-Reset', `${Math.round(result.msBeforeNext / 1000)}`); + ctx.set('X-RateLimit-Limit', `${rateLimiter.points}`); + ctx.set('X-RateLimit-Remaining', `${result.remainingPoints}`); +}; diff --git a/src/lib/ratelimiter.ts b/src/lib/ratelimiter.ts deleted file mode 100644 index 6859e417..00000000 --- a/src/lib/ratelimiter.ts +++ /dev/null @@ -1,44 +0,0 @@ -import config from 'config'; -import type { Context } from 'koa'; -import { RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'; -import requestIp from 'request-ip'; -import { createPersistentRedisClient } from './redis/persistent-client.js'; -import createHttpError from 'http-errors'; - -const redisClient = await createPersistentRedisClient({ legacyMode: true }); - -const rateLimiter = new RateLimiterRedis({ - storeClient: redisClient, - keyPrefix: 'rate', - points: config.get('measurement.rateLimit'), - duration: config.get('measurement.rateLimitReset'), -}); - -const setRateLimitHeaders = (ctx: Context, result: RateLimiterRes) => { - ctx.set('X-RateLimit-Reset', `${Math.round(result.msBeforeNext / 1000)}`); - ctx.set('X-RateLimit-Limit', `${rateLimiter.points}`); - ctx.set('X-RateLimit-Remaining', `${result.remainingPoints}`); -}; - -export const rateLimit = async (ctx: Context, numberOfProbes: number) => { - if (ctx['isAdmin']) { - return; - } - - const clientIp = requestIp.getClientIp(ctx.req) ?? ''; - - try { - const result = await rateLimiter.consume(clientIp, numberOfProbes); - setRateLimitHeaders(ctx, result); - } catch (error) { - if (error instanceof RateLimiterRes) { - const result = await rateLimiter.reward(clientIp, numberOfProbes); - setRateLimitHeaders(ctx, result); - throw createHttpError(429, 'Too Many Probes Requested', { type: 'too_many_probes' }); - } - - throw createHttpError(500); - } -}; - -export default rateLimiter; diff --git a/src/lib/server.ts b/src/lib/server.ts index 38d3982c..0eac0843 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -9,6 +9,7 @@ import { populateCitiesList } from './geoip/city-approximation.js'; import { adoptedProbes } from './adopted-probes.js'; import { reconnectProbes } from './ws/helper/reconnect-probes.js'; import { initPersistentRedisClient } from './redis/persistent-client.js'; +import { auth } from './http/auth.js'; export const createServer = async (): Promise => { await initRedisClient(); @@ -28,6 +29,9 @@ export const createServer = async (): Promise => { await adoptedProbes.syncDashboardData(); adoptedProbes.scheduleSync(); + await auth.syncTokens(); + auth.scheduleSync(); + reconnectProbes(); const { getWsServer } = await import('./ws/server.js'); diff --git a/src/measurement/route/create-measurement.ts b/src/measurement/route/create-measurement.ts index 0569bd4b..8697013c 100644 --- a/src/measurement/route/create-measurement.ts +++ b/src/measurement/route/create-measurement.ts @@ -1,15 +1,17 @@ import config from 'config'; -import type { Context } from 'koa'; import type Router from '@koa/router'; import { getMeasurementRunner } from '../runner.js'; import { bodyParser } from '../../lib/http/middleware/body-parser.js'; +import { corsAuthHandler } from '../../lib/http/middleware/cors.js'; import { validate } from '../../lib/http/middleware/validate.js'; +import { authenticate } from '../../lib/http/middleware/authenticate.js'; import { schema } from '../schema/global-schema.js'; +import type { ExtendedContext } from '../../types.js'; const hostConfig = config.get('server.host'); const runner = getMeasurementRunner(); -const handle = async (ctx: Context): Promise => { +const handle = async (ctx: ExtendedContext): Promise => { const { measurementId, probesCount } = await runner.run(ctx); ctx.status = 202; @@ -22,5 +24,7 @@ const handle = async (ctx: Context): Promise => { }; export const registerCreateMeasurementRoute = (router: Router): void => { - router.post('/measurements', '/measurements', bodyParser(), validate(schema), handle); + router + .options('/measurements', '/measurements', corsAuthHandler()) + .post('/measurements', '/measurements', authenticate, bodyParser(), validate(schema), handle); }; diff --git a/src/measurement/runner.ts b/src/measurement/runner.ts index 56a3ffb1..4c134679 100644 --- a/src/measurement/runner.ts +++ b/src/measurement/runner.ts @@ -1,4 +1,3 @@ -import type { Context } from 'koa'; import config from 'config'; import type { Server } from 'socket.io'; import createHttpError from 'http-errors'; @@ -9,7 +8,8 @@ import { getMetricsAgent, type MetricsAgent } from '../lib/metrics.js'; import type { MeasurementStore } from './store.js'; import { getMeasurementStore } from './store.js'; import type { MeasurementRequest, MeasurementResultMessage, MeasurementProgressMessage, UserRequest } from './types.js'; -import { rateLimit } from '../lib/ratelimiter.js'; +import { rateLimit } from '../lib/rate-limiter.js'; +import type { ExtendedContext } from '../types.js'; export class MeasurementRunner { constructor ( @@ -20,7 +20,7 @@ export class MeasurementRunner { private readonly metrics: MetricsAgent, ) {} - async run (ctx: Context): Promise<{measurementId: string; probesCount: number;}> { + async run (ctx: ExtendedContext): Promise<{measurementId: string; probesCount: number;}> { const userRequest = ctx.request.body as UserRequest; const { onlineProbesMap, allProbes, request } = await this.router.findMatchingProbes(userRequest); diff --git a/src/types.d.ts b/src/types.d.ts index 2e5d4e31..c53b4b7d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,7 +1,10 @@ import type Koa from 'koa'; import type Router from '@koa/router'; import type { DocsLinkContext } from './lib/http/middleware/docs-link.js'; +import type { AuthenticateState } from './lib/http/middleware/authenticate.js'; -export type CustomState = Koa.DefaultState; +export type CustomState = Koa.DefaultState & AuthenticateState; export type CustomContext = Koa.DefaultContext & DocsLinkContext; + +export type ExtendedContext = Router.RouterContext; export type ExtendedMiddleware = Router.Middleware; diff --git a/test/tests/integration/measurement/create-measurement.test.ts b/test/tests/integration/measurement/create-measurement.test.ts index b386765c..002868d6 100644 --- a/test/tests/integration/measurement/create-measurement.test.ts +++ b/test/tests/integration/measurement/create-measurement.test.ts @@ -10,7 +10,7 @@ import type { AdoptedProbes } from '../../../../src/lib/adopted-probes.js'; describe('Create measurement', () => { let addFakeProbe: () => Promise; - let deleteFakeProbes: (socket: Socket) => Promise; + let deleteFakeProbes: () => Promise; let getTestServer; let requestAgent: Agent; let adoptedProbes: AdoptedProbes; @@ -68,7 +68,7 @@ describe('Create measurement', () => { }); after(async () => { - await deleteFakeProbes(probe); + await deleteFakeProbes(); nock.cleanAll(); }); diff --git a/test/tests/integration/measurement/probe-communication.test.ts b/test/tests/integration/measurement/probe-communication.test.ts index d1706b6e..f2afbf14 100644 --- a/test/tests/integration/measurement/probe-communication.test.ts +++ b/test/tests/integration/measurement/probe-communication.test.ts @@ -9,7 +9,7 @@ import nockGeoIpProviders from '../../../utils/nock-geo-ip.js'; describe('Create measurement request', () => { let probe: Socket; let addFakeProbe: (events?: Record) => Promise; - let deleteFakeProbes: (socket: Socket) => Promise; + let deleteFakeProbes: () => Promise; let getTestServer; let requestAgent: Agent; @@ -36,7 +36,7 @@ describe('Create measurement request', () => { }); afterEach(async () => { - await deleteFakeProbes(probe); + await deleteFakeProbes(); nock.cleanAll(); }); diff --git a/test/tests/integration/measurement/timeout-result.test.ts b/test/tests/integration/measurement/timeout-result.test.ts index c4c0496a..410718cc 100644 --- a/test/tests/integration/measurement/timeout-result.test.ts +++ b/test/tests/integration/measurement/timeout-result.test.ts @@ -9,7 +9,7 @@ import nockGeoIpProviders from '../../../utils/nock-geo-ip.js'; describe('Timeout results', () => { let probe: Socket; let addFakeProbe: (events?: Record) => Promise; - let deleteFakeProbes: (socket: Socket) => Promise; + let deleteFakeProbes: () => Promise; let getTestServer; let requestAgent: Agent; let sandbox: sinon.SinonSandbox; @@ -32,7 +32,7 @@ describe('Timeout results', () => { }); afterEach(async () => { - await deleteFakeProbes(probe); + await deleteFakeProbes(); nock.cleanAll(); }); diff --git a/test/tests/integration/middleware/authenticate.test.ts b/test/tests/integration/middleware/authenticate.test.ts new file mode 100644 index 00000000..33129f56 --- /dev/null +++ b/test/tests/integration/middleware/authenticate.test.ts @@ -0,0 +1,178 @@ +import type { Server } from 'node:http'; +import { expect } from 'chai'; +import nock from 'nock'; +import request, { type Agent } from 'supertest'; +import nockGeoIpProviders from '../../../utils/nock-geo-ip.js'; +import { addFakeProbe, deleteFakeProbes, getTestServer } from '../../../utils/server.js'; +import { client } from '../../../../src/lib/sql/client.js'; +import { auth, GP_TOKENS_TABLE, Token } from '../../../../src/lib/http/auth.js'; + +describe('authenticate', () => { + let app: Server; + let requestAgent: Agent; + + before(async () => { + app = await getTestServer(); + requestAgent = request(app); + nockGeoIpProviders(); + const probe = await addFakeProbe(); + probe.emit('probe:status:update', 'ready'); + }); + + beforeEach(async () => { + await client(GP_TOKENS_TABLE).where({ value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=' }).delete(); + await auth.syncTokens(); + }); + + after(async () => { + nock.cleanAll(); + await deleteFakeProbes(); + }); + + it('should accept if no "Authorization" header was passed', async () => { + await requestAgent.post('/v1/measurements') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(202); + }); + + it('should accept if valid token was passed', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + }); + + await auth.syncTokens(); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(202); + }); + + it('should accept if origin is correct', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + origins: JSON.stringify([ 'https://jsdelivr.com' ]), + }); + + await auth.syncTokens(); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .set('Origin', 'https://jsdelivr.com') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(202); + }); + + it('should update "date_last_used" field', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + }); + + await auth.syncTokens(); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(202); + + const tokens = await client(GP_TOKENS_TABLE).select([ 'date_last_used' ]).where({ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + }); + + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + expect(tokens[0]?.date_last_used?.toString()).to.equal(currentDate.toString()); + }); + + it('should get token from db if it is not synced yet', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + }); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(202); + }); + + it('should reject with 401 if invalid token was passed', async () => { + await requestAgent.post('/v1/measurements') + .set('Authorization', 'invalidValue') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(401); + }); + + it('should reject if token is expired', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + expire: new Date('01-01-2024'), + }); + + await auth.syncTokens(); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(401); + }); + + it('should reject if previously not synced token is expired', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + expire: new Date('01-01-2024'), + }); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(401); + }); + + it('should reject if origin is wrong', async () => { + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + origins: JSON.stringify([ 'https://jsdelivr.com' ]), + }); + + await auth.syncTokens(); + + await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4') + .send({ + type: 'ping', + target: 'example.com', + }) + .expect(401); + }); +}); diff --git a/test/tests/integration/middleware/cors.test.ts b/test/tests/integration/middleware/cors.test.ts index d7fe9de1..97609bf8 100644 --- a/test/tests/integration/middleware/cors.test.ts +++ b/test/tests/integration/middleware/cors.test.ts @@ -26,4 +26,18 @@ describe('cors', () => { expect(response.headers['access-control-allow-origin']).to.equal('*'); }); }); + + describe('Access-Control-Allow-Headers header', () => { + it('should include the header with value of *', async () => { + const response = await requestAgent.get('/v1/').set('Origin', 'elocast.com').send() as Response; + + expect(response.headers['access-control-allow-headers']).to.equal('*'); + }); + + it('should include the header with value of *, Authorization', async () => { + const response = await requestAgent.options('/v1/measurements').send() as Response; + + expect(response.headers['access-control-allow-headers']).to.equal('*, Authorization'); + }); + }); }); diff --git a/test/tests/integration/ratelimit.test.ts b/test/tests/integration/ratelimit.test.ts index 40040c20..f7971730 100644 --- a/test/tests/integration/ratelimit.test.ts +++ b/test/tests/integration/ratelimit.test.ts @@ -1,16 +1,17 @@ import type { Server } from 'node:http'; import request, { type Response } from 'supertest'; import requestIp from 'request-ip'; -import type { RateLimiterRedis } from 'rate-limiter-flexible'; import { expect } from 'chai'; import { getTestServer, addFakeProbe, deleteFakeProbes } from '../../utils/server.js'; import nockGeoIpProviders from '../../utils/nock-geo-ip.js'; +import { anonymousRateLimiter, authenticatedRateLimiter } from '../../../src/lib/rate-limiter.js'; +import { client } from '../../../src/lib/sql/client.js'; +import { GP_TOKENS_TABLE } from '../../../src/lib/http/auth.js'; describe('rate limiter', () => { let app: Server; let requestAgent: any; let clientIpv6: string; - let rateLimiterInstance: RateLimiterRedis; before(async () => { app = await getTestServer(); @@ -22,9 +23,6 @@ describe('rate limiter', () => { // Koa sees ipv6-ipv4 monster clientIpv6 = `::ffff:${clientIp ?? '127.0.0.1'}`; - const rateLimiter = await import('../../../src/lib/ratelimiter.js'); - rateLimiterInstance = rateLimiter.default; - nockGeoIpProviders(); nockGeoIpProviders(); @@ -33,15 +31,22 @@ describe('rate limiter', () => { probe1.emit('probe:status:update', 'ready'); probe2.emit('probe:status:update', 'ready'); + + await client(GP_TOKENS_TABLE).insert({ + user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959', + value: '7emhYIar8eLtwAAjyXUn+h3Cj+Xc9BQcLMC6JAX9fHQ=', + }); }); afterEach(async () => { - await rateLimiterInstance.delete(clientIpv6); + await anonymousRateLimiter.delete(clientIpv6); + await authenticatedRateLimiter.delete('89da69bd-a236-4ab7-9c5d-b5f52ce09959'); }); after(async () => { await deleteFakeProbes(); + await client(GP_TOKENS_TABLE).where({ value: '7emhYIar8eLtwAAjyXUn+h3Cj+Xc9BQcLMC6JAX9fHQ=' }).delete(); }); describe('headers', () => { @@ -72,6 +77,19 @@ describe('rate limiter', () => { expect(response.headers['x-ratelimit-reset']).to.exist; }); + it('should include headers (authenticated POST)', async () => { + const response = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + }).expect(202) as Response; + + expect(response.headers['x-ratelimit-limit']).to.exist; + expect(response.headers['x-ratelimit-remaining']).to.exist; + expect(response.headers['x-ratelimit-reset']).to.exist; + }); + it('should change values on multiple requests (POST)', async () => { const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', @@ -91,11 +109,35 @@ describe('rate limiter', () => { expect(response2.headers['x-ratelimit-remaining']).to.equal('99998'); expect(response2.headers['x-ratelimit-reset']).to.equal('3600'); }); + + it('should change values on multiple requests (authenticated POST)', async () => { + const response = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + }).expect(202) as Response; + + expect(response.headers['x-ratelimit-limit']).to.equal('250'); + expect(response.headers['x-ratelimit-remaining']).to.equal('249'); + expect(response.headers['x-ratelimit-reset']).to.equal('3600'); + + const response2 = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + }).expect(202) as Response; + + expect(response2.headers['x-ratelimit-limit']).to.equal('250'); + expect(response2.headers['x-ratelimit-remaining']).to.equal('248'); + expect(response2.headers['x-ratelimit-reset']).to.equal('3600'); + }); }); - describe('access', () => { + describe('anonymous access', () => { it('should succeed (limit not reached)', async () => { - await rateLimiterInstance.set(clientIpv6, 0, 0); + await anonymousRateLimiter.set(clientIpv6, 0, 0); const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', @@ -106,7 +148,7 @@ describe('rate limiter', () => { }); it('should fail (limit reached) (start at 100)', async () => { - await rateLimiterInstance.set(clientIpv6, 100000, 0); + await anonymousRateLimiter.set(clientIpv6, 100000, 0); const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', @@ -117,7 +159,7 @@ describe('rate limiter', () => { }); it('should consume all points successfully or none at all (cost > remaining > 0)', async () => { - await rateLimiterInstance.set(clientIpv6, 99999, 0); // 1 remaining + await anonymousRateLimiter.set(clientIpv6, 99999, 0); // 1 remaining const response = await requestAgent.post('/v1/measurements').send({ type: 'ping', @@ -128,4 +170,46 @@ describe('rate limiter', () => { expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(1); }); }); + + describe('authenticated access', () => { + it('should succeed (limit not reached)', async () => { + await authenticatedRateLimiter.set('89da69bd-a236-4ab7-9c5d-b5f52ce09959', 0, 0); + + const response = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + }).expect(202) as Response; + + expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(249); + }); + + it('should fail (limit reached) (start at 100)', async () => { + await authenticatedRateLimiter.set('89da69bd-a236-4ab7-9c5d-b5f52ce09959', 250, 0); + + const response = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + }).expect(429) as Response; + + expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(0); + }); + + it('should consume all points successfully or none at all (cost > remaining > 0)', async () => { + await authenticatedRateLimiter.set('89da69bd-a236-4ab7-9c5d-b5f52ce09959', 249, 0); // 1 remaining + + const response = await requestAgent.post('/v1/measurements') + .set('Authorization', 'Bearer v2lUHEVLtVSskaRKDBabpyp4AkzdMnob') + .send({ + type: 'ping', + target: 'jsdelivr.com', + limit: 2, + }).expect(429) as Response; + + expect(Number(response.headers['x-ratelimit-remaining'])).to.equal(1); + }); + }); }); diff --git a/test/tests/unit/adopted-probes.test.ts b/test/tests/unit/adopted-probes.test.ts index e4d990ed..4cde60ae 100644 --- a/test/tests/unit/adopted-probes.test.ts +++ b/test/tests/unit/adopted-probes.test.ts @@ -4,78 +4,78 @@ import * as sinon from 'sinon'; import { AdoptedProbes } from '../../../src/lib/adopted-probes.js'; import type { Probe } from '../../../src/probe/types.js'; -const defaultAdoptedProbe = { - userId: '3cff97ae-4a0a-4f34-9f1a-155e6def0a45', - username: 'jimaek', - ip: '1.1.1.1', - uuid: '1-1-1-1-1', - lastSyncDate: new Date('1970-01-01'), - tags: '[{"prefix":"jimaek","value":"dashboardtag"}]', - isCustomCity: 0, - status: 'ready', - version: '0.26.0', - country: 'IE', - state: null, - countryOfCustomCity: '', - city: 'Dublin', - latitude: 53.3331, - longitude: -6.2489, - asn: 16509, - network: 'Amazon.com, Inc.', -}; - -const defaultConnectedProbe: Probe = { - ipAddress: '1.1.1.1', - uuid: '1-1-1-1-1', - status: 'ready', - version: '0.26.0', - nodeVersion: 'v18.17.0', - location: { - continent: 'EU', - region: 'Northern Europe', +describe('AdoptedProbes', () => { + const defaultAdoptedProbe = { + userId: '3cff97ae-4a0a-4f34-9f1a-155e6def0a45', + username: 'jimaek', + ip: '1.1.1.1', + uuid: '1-1-1-1-1', + lastSyncDate: new Date('1970-01-01'), + tags: '[{"prefix":"jimaek","value":"dashboardtag"}]', + isCustomCity: 0, + status: 'ready', + version: '0.26.0', country: 'IE', state: null, + countryOfCustomCity: '', city: 'Dublin', - normalizedCity: 'dublin', - asn: 16509, latitude: 53.3331, longitude: -6.2489, + asn: 16509, network: 'Amazon.com, Inc.', - normalizedNetwork: 'amazon.com, inc.', - }, - isHardware: false, - hardwareDevice: null, - tags: [], - index: [], - client: '', - host: '', - resolvers: [], - stats: { - cpu: { - count: 0, - load: [], + }; + + const defaultConnectedProbe: Probe = { + ipAddress: '1.1.1.1', + uuid: '1-1-1-1-1', + status: 'ready', + version: '0.26.0', + nodeVersion: 'v18.17.0', + location: { + continent: 'EU', + region: 'Northern Europe', + country: 'IE', + state: null, + city: 'Dublin', + normalizedCity: 'dublin', + asn: 16509, + latitude: 53.3331, + longitude: -6.2489, + network: 'Amazon.com, Inc.', + normalizedNetwork: 'amazon.com, inc.', }, - jobs: { count: 0 }, - }, -}; - -const selectStub = sinon.stub(); -const updateStub = sinon.stub(); -const deleteStub = sinon.stub(); -const rawStub = sinon.stub(); -const whereStub = sinon.stub().returns({ - update: updateStub, - delete: deleteStub, -}); -const sqlStub = sinon.stub().returns({ - select: selectStub, - where: whereStub, -}) as sinon.SinonStub & {raw: any}; -sqlStub.raw = rawStub; -const fetchSocketsStub = sinon.stub().resolves([]); -let sandbox: sinon.SinonSandbox; + isHardware: false, + hardwareDevice: null, + tags: [], + index: [], + client: '', + host: '', + resolvers: [], + stats: { + cpu: { + count: 0, + load: [], + }, + jobs: { count: 0 }, + }, + }; + + const selectStub = sinon.stub(); + const updateStub = sinon.stub(); + const deleteStub = sinon.stub(); + const rawStub = sinon.stub(); + const whereStub = sinon.stub().returns({ + update: updateStub, + delete: deleteStub, + }); + const sqlStub = sinon.stub().returns({ + select: selectStub, + where: whereStub, + }) as sinon.SinonStub & {raw: any}; + sqlStub.raw = rawStub; + const fetchSocketsStub = sinon.stub().resolves([]); + let sandbox: sinon.SinonSandbox; -describe('AdoptedProbes', () => { beforeEach(() => { sandbox = sinon.createSandbox({ useFakeTimers: true }); sinon.resetHistory(); @@ -96,7 +96,7 @@ describe('AdoptedProbes', () => { await adoptedProbes.syncDashboardData(); expect(sqlStub.callCount).to.equal(1); - expect(sqlStub.args[0]).deep.equal([{ probes: 'gp_adopted_probes' }]); + expect(sqlStub.args[0]).deep.equal([ 'gp_adopted_probes' ]); expect(selectStub.callCount).to.equal(1); }); diff --git a/test/tests/unit/auth.test.ts b/test/tests/unit/auth.test.ts new file mode 100644 index 00000000..a228c5f0 --- /dev/null +++ b/test/tests/unit/auth.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import type { Knex } from 'knex'; +import * as sinon from 'sinon'; +import { Auth } from '../../../src/lib/http/auth.js'; + +describe('Auth', () => { + const updateStub = sinon.stub(); + const selectStub = sinon.stub(); + const whereStub = sinon.stub().returns({ + update: updateStub, + select: selectStub, + }); + const sqlStub = sinon.stub().returns({ + where: whereStub, + }) as sinon.SinonStub & {raw: any}; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox({ useFakeTimers: true }); + sinon.resetHistory(); + }); + + afterEach(() => { + selectStub.reset(); + sandbox.restore(); + }); + + it('should sync tokens every minute', async () => { + const auth = new Auth(sqlStub as unknown as Knex); + + selectStub.onCall(1).resolves([{ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + user_created: 'user1', + }]); + + selectStub.onCall(2).resolves([]); + + auth.scheduleSync(); + await sandbox.clock.tickAsync(60000); + + const user1 = await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + expect(user1).to.equal('user1'); + const user2 = await auth.validate('ve7w6UTaOt3aXpctEk8wJQtkJwz2IOMY', 'https://jsdelivr.com'); + expect(user2).to.equal(null); + + selectStub.onCall(3).resolves([{ + value: 'r8S12cmTjYeMIb/KcYE2a/LLQGBdHdhC0VxTyt2eeAQ=', + user_created: 'user2', + }]); + + selectStub.onCall(4).resolves([]); + + await sandbox.clock.tickAsync(60000); + + const user1afterSync = await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + expect(user1afterSync).to.equal(null); + const user2afterSync = await auth.validate('ve7w6UTaOt3aXpctEk8wJQtkJwz2IOMY', 'https://jsdelivr.com'); + expect(user2afterSync).to.equal('user2'); + }); + + it('should not do sql requests for synced tokens', async () => { + const auth = new Auth(sqlStub as unknown as Knex); + + selectStub.resolves([{ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + user_created: 'user1', + }]); + + await auth.syncTokens(); + + const user = await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + + expect(user).to.equal('user1'); + expect(selectStub.callCount).to.equal(1); + }); + + it('should cache new token', async () => { + const auth = new Auth(sqlStub as unknown as Knex); + + selectStub.resolves([{ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + user_created: 'user1', + }]); + + const user = await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + + expect(user).to.equal('user1'); + expect(selectStub.callCount).to.equal(1); + }); + + it('should not update date_last_used if it is actual', async () => { + const auth = new Auth(sqlStub as unknown as Knex); + + selectStub.resolves([{ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + user_created: 'user1', + date_last_used: new Date(), + }]); + + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + + expect(updateStub.callCount).to.equal(0); + }); + + it('should update date_last_used only once', async () => { + const auth = new Auth(sqlStub as unknown as Knex); + + selectStub.resolves([{ + value: 'aGmWPCV1JN/qmYl27g8VpBjhCmTpbFcbdrWgTvEtqo4=', + user_created: 'user1', + }]); + + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + await auth.validate('VRbBNLbHkckWRcPmWv0Kj3xwBpi32Ij4', 'https://jsdelivr.com'); + + expect(updateStub.args[0]).to.deep.equal([{ date_last_used: new Date() }]); + expect(updateStub.callCount).to.equal(1); + }); +}); diff --git a/test/tests/unit/measurement/runner.test.ts b/test/tests/unit/measurement/runner.test.ts index 60954e77..910481d8 100644 --- a/test/tests/unit/measurement/runner.test.ts +++ b/test/tests/unit/measurement/runner.test.ts @@ -1,4 +1,3 @@ -import type { Context } from 'koa'; import * as sinon from 'sinon'; import { Server } from 'socket.io'; import { expect } from 'chai'; @@ -10,6 +9,7 @@ import type { Probe } from '../../../../src/probe/types.js'; import type { MeasurementRunner } from '../../../../src/measurement/runner.js'; import type { MeasurementRecord, MeasurementResultMessage } from '../../../../src/measurement/types.js'; import createHttpError from 'http-errors'; +import type { ExtendedContext } from '../../../../src/types.js'; const getProbe = (id: number) => ({ client: id } as unknown as Probe); @@ -73,7 +73,7 @@ describe('MeasurementRunner', () => { request: { body: request, }, - } as unknown as Context); + } as unknown as ExtendedContext); expect(router.findMatchingProbes.callCount).to.equal(1); @@ -175,7 +175,7 @@ describe('MeasurementRunner', () => { request: { body: request, }, - } as unknown as Context); + } as unknown as ExtendedContext); expect(router.findMatchingProbes.callCount).to.equal(1); @@ -266,7 +266,7 @@ describe('MeasurementRunner', () => { sandbox.restore(); }); - it('should call ratelimiter with the number of online probes', async () => { + it('should call rate limiter with the number of online probes', async () => { const request = { type: 'ping' as const, target: 'jsdelivr.com', @@ -290,7 +290,7 @@ describe('MeasurementRunner', () => { request: { body: request, }, - } as unknown as Context; + } as unknown as ExtendedContext; await runner.run(ctx); @@ -322,7 +322,7 @@ describe('MeasurementRunner', () => { request: { body: request, }, - } as unknown as Context).catch((err: unknown) => err); + } as unknown as ExtendedContext).catch((err: unknown) => err); expect(err).to.deep.equal(createHttpError(422, 'No suitable probes found.', { type: 'no_probes_found' })); expect(store.markFinished.callCount).to.equal(0); }); @@ -351,7 +351,7 @@ describe('MeasurementRunner', () => { request: { body: request, }, - } as unknown as Context); + } as unknown as ExtendedContext); expect(store.markFinished.callCount).to.equal(1); expect(store.markFinished.args[0]).to.deep.equal([ 'measurementid' ]);