Skip to content

Commit d6de1e8

Browse files
feat: add token validation
1 parent ee40891 commit d6de1e8

File tree

6 files changed

+121
-6
lines changed

6 files changed

+121
-6
lines changed

Diff for: package-lock.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"type": "module",
66
"main": "dist/src/index.js",
77
"dependencies": {
8+
"@isaacs/ttlcache": "^1.4.1",
89
"@koa/router": "^12.0.1",
910
"@maxmind/geoip2-node": "^4.2.0",
1011
"@redocly/openapi-core": "^1.6.0",

Diff for: src/lib/adopted-probes.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class AdoptedProbes {
142142
}
143143

144144
private async fetchAdoptedProbes () {
145-
const rows = await this.sql({ probes: ADOPTED_PROBES_TABLE }).select<Row[]>();
145+
const rows = await this.sql(ADOPTED_PROBES_TABLE).select<Row[]>();
146146

147147

148148
const adoptedProbes: AdoptedProbe[] = rows.map(row => ({

Diff for: src/lib/http/auth.ts

+101-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,112 @@
1+
import { createHash } from 'node:crypto';
12
import type { Knex } from 'knex';
3+
import TTLCache from '@isaacs/ttlcache';
4+
import { scopedLogger } from '../logger.js';
25
import { client } from '../sql/client.js';
36

7+
export const GP_TOKENS_TABLE = 'gp_tokens';
8+
9+
const logger = scopedLogger('auth');
10+
11+
type Token = {
12+
value: string,
13+
expire: Date | null,
14+
origins: string[],
15+
date_last_used: Date | null
16+
}
17+
18+
type Row = Omit<Token, 'origins'> & {
19+
origins: string | null,
20+
}
21+
422
export class Auth {
23+
private validTokens = new TTLCache<string, Token>({ ttl: 2 * 60 * 1000 });
24+
private invalidTokens = new TTLCache<string, true>({ ttl: 2 * 60 * 1000 });
525
constructor (private readonly sql: Knex) {}
626

7-
async validate (token: string) {
27+
scheduleSync () {
28+
setTimeout(() => {
29+
this.syncTokens()
30+
.finally(() => this.scheduleSync())
31+
.catch(error => logger.error(error));
32+
}, 60_000);
33+
}
34+
35+
async syncTokens () {
36+
const tokens = await this.fetchTokens();
37+
tokens.forEach((token) => {
38+
if (token.expire && this.isExpired(token.expire)) {
39+
this.invalidTokens.set(token.value, true);
40+
} else {
41+
this.validTokens.set(token.value, token);
42+
}
43+
});
44+
}
45+
46+
async syncSpecificToken (hash: string) {
47+
const tokens = await this.fetchTokens({ value: hash });
48+
49+
if (tokens.length === 0) {
50+
this.invalidTokens.set(hash, true);
51+
return undefined;
52+
}
53+
54+
const token = tokens[0]!;
55+
56+
if (token.expire && this.isExpired(token.expire)) {
57+
this.invalidTokens.set(hash, true);
58+
return undefined;
59+
}
60+
61+
this.validTokens.set(hash, token);
62+
return token;
63+
}
64+
65+
async fetchTokens (filter: Partial<Row> = {}) {
66+
const rows = await this.sql(GP_TOKENS_TABLE).select<Row[]>('value', 'expire', 'origins', 'date_last_used').where(filter);
67+
68+
const tokens: Token[] = rows.map(row => ({
69+
...row,
70+
origins: (row.origins ? JSON.parse(row.origins) as string[] : []),
71+
}));
72+
73+
return tokens;
74+
}
75+
76+
async validate (tokenString: string, origin: string) {
77+
const bytes = Buffer.from(tokenString, 'base64');
78+
const hash = createHash('sha256').update(bytes).digest('base64');
79+
80+
if (this.invalidTokens.get(hash)) {
81+
return false;
82+
}
83+
84+
let token = this.validTokens.get(hash);
85+
86+
if (!token) {
87+
token = await this.syncSpecificToken(hash);
88+
}
89+
90+
if (!token) {
91+
return false;
92+
}
93+
94+
if (!this.isValidOrigin(origin, token.origins)) {
95+
return false;
96+
}
97+
898
return true;
999
}
100+
101+
private isExpired (date: Date) {
102+
const currentDate = new Date();
103+
currentDate.setHours(0, 0, 0, 0);
104+
return date < currentDate;
105+
}
106+
107+
private isValidOrigin (origin: string, validOrigins: string[]) {
108+
return validOrigins.length > 0 ? validOrigins.includes(origin) : true;
109+
}
10110
}
11111

12112
export const auth = new Auth(client);

Diff for: src/lib/http/middleware/is-authenticated.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import type { Context, Next } from 'koa';
22
import { auth } from '../auth.js';
33

44
export const isAuthenticatedMw = async (ctx: Context, next: Next) => {
5-
const { header } = ctx.request;
5+
const { headers } = ctx.request;
66

7-
if (header && header.authorization) {
8-
const parts = header.authorization.split(' ');
7+
if (headers && headers.authorization) {
8+
const parts = headers.authorization.split(' ');
99

1010
if (parts.length !== 2 || parts[0] !== 'Bearer') {
1111
ctx.status = 401;
1212
return;
1313
}
1414

15-
const isValid = await auth.validate(parts[1]!);
15+
const origin = ctx.get('Origin');
16+
const isValid = await auth.validate(parts[1]!, origin);
1617

1718
if (!isValid) {
1819
ctx.status = 401;

Diff for: src/lib/server.ts

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { populateCitiesList } from './geoip/city-approximation.js';
99
import { adoptedProbes } from './adopted-probes.js';
1010
import { reconnectProbes } from './ws/helper/reconnect-probes.js';
1111
import { initPersistentRedisClient } from './redis/persistent-client.js';
12+
import { auth } from './http/auth.js';
1213

1314
export const createServer = async (): Promise<Server> => {
1415
await initRedisClient();
@@ -28,6 +29,9 @@ export const createServer = async (): Promise<Server> => {
2829
await adoptedProbes.syncDashboardData();
2930
adoptedProbes.scheduleSync();
3031

32+
await auth.syncTokens();
33+
auth.scheduleSync();
34+
3135
reconnectProbes();
3236

3337
const { getWsServer } = await import('./ws/server.js');

0 commit comments

Comments
 (0)