Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: anonymous tokens #553

Merged
merged 2 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/v1/components/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ components:
Locations defined in a single string instead of the respective location properties.
The API performs fuzzy matching on the `country`, `city`, `state`, `continent`, `region`, `asn` (using `AS` prefix, e.g., `AS123`), `tags`, and `network` values.
Supports full names, ISO codes (where applicable), and common aliases.
Multiple conditions can be combined using the `+` character.
Multiple conditions can be combined using the `+` character, which behaves like a logical `AND`.
limit:
type: integer
description: |
Expand Down
4 changes: 2 additions & 2 deletions src/lib/http/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const logger = scopedLogger('auth');
const TOKEN_TTL = 2 * 60 * 1000;

export type Token = {
user_created: string,
user_created?: string,
value: string,
expire: Date | null,
scopes: string[],
Expand Down Expand Up @@ -127,7 +127,7 @@ export class Auth {
}

await this.updateLastUsedDate(token);
return { userId: token.user_created, scopes: token.scopes };
return { userId: token.user_created, scopes: token.scopes, hashedToken: token.value };
}

private async updateLastUsedDate (token: Token) {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/http/middleware/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const authenticate = (): ExtendedMiddleware => {
return;
}

ctx.state.user = { id: result.userId, scopes: result.scopes, authMode: 'token' };
ctx.state.user = { id: result.userId, scopes: result.scopes, authMode: 'token', hashedToken: result.hashedToken };
} else if (sessionCookie) {
try {
const result = await jwtVerify<SessionCookiePayload>(sessionCookie, sessionKey);
Expand All @@ -53,4 +53,4 @@ export const authenticate = (): ExtendedMiddleware => {
};

export type AuthenticateOptions = { session: { cookieName: string, cookieSecret: string } };
export type AuthenticateState = { user?: { id: string, scopes?: string[], authMode: 'cookie' | 'token' } };
export type AuthenticateState = { user?: { id: string | undefined, scopes?: string[], hashedToken?: string, authMode: 'cookie' | 'token' } };
4 changes: 2 additions & 2 deletions src/lib/rate-limiter/rate-limiter-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ const getRateLimiter = (ctx: ExtendedContext): {
id: string,
rateLimiter: RateLimiterRedis
} => {
if (ctx.state.user?.id) {
if (ctx.state.user) {
return {
type: 'user',
id: ctx.state.user.id,
id: ctx.state.user.id ?? ctx.state.user.hashedToken ?? '',
Comment on lines -30 to +33
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this is wrong. The anonymous tokens should go to anonymousRateLimiter, not to authenticatedRateLimiter. @alexey-yarmosh please update in another PR.

rateLimiter: authenticatedRateLimiter,
};
}
Expand Down
32 changes: 22 additions & 10 deletions src/probe/probes-location-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const locationKeyMap = [

export class ProbesLocationFilter {
static magicFilter (probes: Probe[], magicLocation: string) {
let filteredProbes = probes;
let resultProbes = probes;
const keywords = magicLocation.split('+');

for (const keyword of keywords) {
Expand All @@ -31,26 +31,38 @@ export class ProbesLocationFilter {
}, Number.POSITIVE_INFINITY);
const noExactMatches = closestExactMatchPosition === Number.POSITIVE_INFINITY;

let filteredProbes = [];

if (noExactMatches) {
filteredProbes = filteredProbes.filter(probe => ProbesLocationFilter.getIndexPosition(probe, keyword) !== -1);
filteredProbes = resultProbes.filter(probe => ProbesLocationFilter.getIndexPosition(probe, keyword) !== -1);
} else {
filteredProbes = filteredProbes.filter(probe => ProbesLocationFilter.getExactIndexPosition(probe, keyword) === closestExactMatchPosition);
filteredProbes = resultProbes.filter(probe => ProbesLocationFilter.getExactIndexPosition(probe, keyword) === closestExactMatchPosition);
}

if (filteredProbes.length === 0) {
filteredProbes = resultProbes.filter(probe => ProbesLocationFilter.hasUserTag(probe, keyword.toLowerCase()));
}

resultProbes = filteredProbes;
}

return filteredProbes;
return resultProbes;
}

static getExactIndexPosition (probe: Probe, filterValue: string) {
return probe.index.findIndex(category => category.some(index => index === filterValue.toLowerCase().replaceAll('-', ' ').trim()));
}

static getExactIndexPosition (probe: Probe, value: string) {
return probe.index.findIndex(category => category.some(index => index === value.toLowerCase().replaceAll('-', ' ').trim()));
static getIndexPosition (probe: Probe, filterValue: string) {
return probe.index.findIndex(category => category.some(index => index.includes(filterValue.toLowerCase().replaceAll('-', ' ').trim())));
}

static getIndexPosition (probe: Probe, value: string) {
return probe.index.findIndex(category => category.some(index => index.includes(value.toLowerCase().replaceAll('-', ' ').trim())));
static hasTag (probe: Probe, filterValue: string) {
return probe.tags.some(({ value }) => value.toLowerCase() === filterValue);
}

static hasTag (probe: Probe, tag: string) {
return probe.tags.some(({ value }) => value.toLowerCase() === tag);
static hasUserTag (probe: Probe, filterValue: string) {
return probe.tags.filter(({ type }) => type === 'user').some(({ value }) => value.toLowerCase() === filterValue);
}

public filterGloballyDistibuted (probes: Probe[], limit: number): Probe[] {
Expand Down
91 changes: 85 additions & 6 deletions test/tests/integration/measurement/create-measurement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ import type { Socket } from 'socket.io-client';
import nockGeoIpProviders from '../../../utils/nock-geo-ip.js';
import { client } from '../../../../src/lib/sql/client.js';
import type { ProbeOverride } from '../../../../src/lib/override/probe-override.js';
import { waitForProbesUpdate } from '../../../utils/server.js';
import geoIpMocks from '../../../mocks/nock-geoip.json' assert { type: 'json' };

describe('Create measurement', () => {
let addFakeProbe: () => Promise<Socket>;
let deleteFakeProbes: () => Promise<void>;
let deleteFakeProbes: (probes?: Socket[]) => Promise<void>;
let waitForProbesUpdate: () => Promise<void>;
let getTestServer;
let requestAgent: Agent;
let probeOverride: ProbeOverride;
let ADOPTED_PROBES_TABLE: string;

before(async () => {
await td.replaceEsm('../../../../src/lib/ip-ranges.ts', { getRegion: () => 'gcp-us-west4', populateMemList: () => Promise.resolve() });
({ getTestServer, addFakeProbe, deleteFakeProbes } = await import('../../../utils/server.js'));
({ getTestServer, addFakeProbe, deleteFakeProbes, waitForProbesUpdate } = await import('../../../utils/server.js'));
({ ADOPTED_PROBES_TABLE } = await import('../../../../src/lib/override/adopted-probes.js'));
({ probeOverride } = await import('../../../../src/lib/ws/server.js'));
const app = await getTestServer();
Expand Down Expand Up @@ -754,15 +755,93 @@ describe('Create measurement', () => {
});
});

it('should not use create measurement with adopted tag in magic field "magic: ["u-jsdelivr-dashboard-tag"]" location', async () => {
describe('user tag in magic field', () => {
let probe2: Socket;
before(async () => {
nock('https://ipmap-api.ripe.net/v1/locate/').get(/.*/).reply(400);
nock('https://api.ip2location.io').get(/.*/).reply(400);
nock('https://globalping-geoip.global.ssl.fastly.net').get(/.*/).reply(400);
nock('https://geoip.maxmind.com/geoip/v2.1/city/').get(/.*/).reply(400);

// Creating an AR probe which has a tag value inside of the content (network name).
nock('https://ipinfo.io').get(/.*/).reply(200, {
...geoIpMocks.ipinfo.argentina,
org: 'AS61004 InterBS u-jsdelivr-dashboard-tag S.R.L.',
});

probe2 = await addFakeProbe();
probe2.emit('probe:status:update', 'ready');
probe2.emit('probe:isIPv4Supported:update', true);
probe2.emit('probe:isIPv6Supported:update', true);
await waitForProbesUpdate();
});

after(() => {
deleteFakeProbes([ probe2 ]);
});

it('should prefer a probe with a match in other field over the probe with match in user tag', async () => {
let measurementId;
await requestAgent.post('/v1/measurements')
.send({
type: 'ping',
target: 'example.com',
locations: [{ magic: 'u-jsdelivr-dashboard-tag', limit: 2 }],
})
.expect(202)
.expect((response) => {
measurementId = response.body.id;
expect(response.body.id).to.exist;
expect(response.header['location']).to.exist;
expect(response.body.probesCount).to.equal(1);
expect(response).to.matchApiSchema();
});

await requestAgent.get(`/v1/measurements/${measurementId}`)
.expect(200)
.expect((response) => {
expect(response.body.results[0].probe.country).to.equal('AR');
});
});
});

it('should prefer a probe with user tag in a magic field if there are no matches in other fields', async () => {
let measurementId;
await requestAgent.post('/v1/measurements')
.send({
type: 'ping',
target: 'example.com',
locations: [{ magic: 'u-jsdelivr-dashboard-tag', limit: 2 }],
})
.expect(422).expect((response) => {
expect(response.body.error.message).to.equal('No suitable probes supporting IPv4 found.');
.expect(202)
.expect((response) => {
measurementId = response.body.id;
expect(response.body.id).to.exist;
expect(response.header['location']).to.exist;
expect(response.body.probesCount).to.equal(1);
expect(response).to.matchApiSchema();
});

await requestAgent.get(`/v1/measurements/${measurementId}`)
.expect(200)
.expect((response) => {
expect(response.body.results[0].probe.country).to.equal('US');
});
});

it('should prefer a probe with any-case user tag in a magic field if there are no matches in other fields', async () => {
await requestAgent.post('/v1/measurements')
.send({
type: 'ping',
target: 'example.com',
locations: [{ magic: 'U-JSdelivr-Dashboard-TAG', limit: 2 }],
})
.expect(202)
.expect((response) => {
expect(response.body.id).to.exist;
expect(response.header['location']).to.exist;
expect(response.body.probesCount).to.equal(1);
expect(response).to.matchApiSchema();
});
});
});
Expand Down
20 changes: 18 additions & 2 deletions test/tests/integration/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ describe('rate limiter', () => {

await waitForProbesUpdate();

await client(GP_TOKENS_TABLE).insert({
await client(GP_TOKENS_TABLE).insert([{
name: 'test token',
user_created: '89da69bd-a236-4ab7-9c5d-b5f52ce09959',
value: 'Xj6kuKFEQ6zI60mr+ckHG7yQcIFGMJFzvtK9PBQ69y8=', // token: qz5kdukfcr3vggv3xbujvjwvirkpkkpx
});
}, {
name: 'anon token',
user_created: null,
value: '1CJTN06QAyM2JYA3r2FwaSytXEWg1r50xNlUyC1G98w=', // token: t6jy4n6nw5jdqxhs5wlkvw7tqsabt734
}]);
});


Expand Down Expand Up @@ -279,6 +283,18 @@ describe('rate limiter', () => {

expect(response.headers['retry-after']).to.equal('5');
});

it('should use hashed token as a key for anonymous tokens', async () => {
await requestAgent.post('/v1/measurements')
.set('Authorization', 'Bearer t6jy4n6nw5jdqxhs5wlkvw7tqsabt734')
.send({
type: 'ping',
target: 'jsdelivr.com',
}).expect(202) as Response;

const rateLimiterRes = await authenticatedPostRateLimiter.get(`1CJTN06QAyM2JYA3r2FwaSytXEWg1r50xNlUyC1G98w=`);
expect(rateLimiterRes?.remainingPoints).to.equal(249);
});
});

describe('access with credits', () => {
Expand Down
28 changes: 24 additions & 4 deletions test/tests/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ describe('Auth', () => {
await clock.tickAsync(60_000);

const user1 = await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');
expect(user1).to.deep.equal({ userId: 'user1', scopes: [] });
expect(user1).to.deep.equal({
userId: 'user1',
scopes: [],
hashedToken: '/bSluuDrAPX9zIiZZ/hxEKARwOg+e//EdJgCFpmApbg=',
});

const user2 = await auth.validate('vumzijbzihrskmc2hj34yw22batpibmt', 'https://jsdelivr.com');
expect(user2).to.equal(null);

Expand All @@ -53,7 +58,12 @@ describe('Auth', () => {
const user1afterSync = await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');
expect(user1afterSync).to.equal(null);
const user2afterSync = await auth.validate('vumzijbzihrskmc2hj34yw22batpibmt', 'https://jsdelivr.com');
expect(user2afterSync).to.deep.equal({ userId: 'user2', scopes: [] });
expect(user2afterSync).to.deep.equal({
userId: 'user2',
scopes: [],
hashedToken: '8YZ2pZoGQxfOeEGvUUkagX1yizZckq3weL+IN0chvU0=',
});

auth.unscheduleSync();
});

Expand All @@ -72,7 +82,12 @@ describe('Auth', () => {
await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');
await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');

expect(user).to.deep.equal({ userId: 'user1', scopes: [] });
expect(user).to.deep.equal({
userId: 'user1',
scopes: [],
hashedToken: '/bSluuDrAPX9zIiZZ/hxEKARwOg+e//EdJgCFpmApbg=',
});

expect(selectStub.callCount).to.equal(1);
});

Expand All @@ -89,7 +104,12 @@ describe('Auth', () => {
await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');
await auth.validate('hf2fnprguymlgliirdk7qv23664c2xcr', 'https://jsdelivr.com');

expect(user).to.deep.equal({ userId: 'user1', scopes: [] });
expect(user).to.deep.equal({
userId: 'user1',
scopes: [],
hashedToken: '/bSluuDrAPX9zIiZZ/hxEKARwOg+e//EdJgCFpmApbg=',
});

expect(selectStub.callCount).to.equal(1);
});

Expand Down
4 changes: 2 additions & 2 deletions test/utils/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export const addFakeProbes = async (count: number, events: object = {}, options:
});
};

export const deleteFakeProbes = async (): Promise<void> => {
const sockets = await fetchRawSockets();
export const deleteFakeProbes = async (socketsToDelete?: Socket[]): Promise<void> => {
const sockets = socketsToDelete?.length ? socketsToDelete : await fetchRawSockets();

for (const socket of sockets) {
socket.disconnect(true);
Expand Down