Skip to content

Commit

Permalink
api: implement support for api keys (#803)
Browse files Browse the repository at this point in the history
  • Loading branch information
dumbmoron authored Oct 5, 2024
2 parents 4317b12 + 3691e2e commit 4ed2df6
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 31 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ build
.env.*
!.env.example
cookies.json
keys.json

# docker
docker-compose.yml
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"express-rate-limit": "^6.3.0",
"ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7",
"ipaddr.js": "2.1.0",
"ipaddr.js": "2.2.0",
"nanoid": "^4.0.2",
"node-cache": "^5.1.2",
"psl": "1.9.0",
Expand Down
5 changes: 5 additions & 0 deletions api/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const env = {
&& process.env.TURNSTILE_SECRET
&& process.env.JWT_SECRET,

apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
authRequired: process.env.API_AUTH_REQUIRED === '1',

keyReloadInterval: 900,

enabledServices,
}

Expand Down
89 changes: 63 additions & 26 deletions api/src/core/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { verifyTurnstileToken } from "../security/turnstile.js";
import { friendlyServiceName } from "../processing/service-alias.js";
import { verifyStream, getInternalStream } from "../stream/manage.js";
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
import * as APIKeys from "../security/api-keys.js";

const git = {
branch: await getBranch(),
Expand Down Expand Up @@ -57,34 +58,40 @@ export const runAPI = (express, app, __dirname) => {
git,
})

const handleRateExceeded = (_, res) => {
const { status, body } = createResponse("error", {
code: "error.api.rate_exceeded",
context: {
limit: env.rateLimitWindow
}
});
return res.status(status).json(body);
};

const sessionLimiter = rateLimit({
windowMs: 60000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: handleRateExceeded
});

const apiLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
max: env.rateLimitMax,
max: (req) => req.rateLimitMax || env.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => {
if (req.authorized) {
return generateHmac(req.header("Authorization"), ipSalt);
}
return generateHmac(getIP(req), ipSalt);
},
handler: (req, res) => {
const { status, body } = createResponse("error", {
code: "error.api.rate_exceeded",
context: {
limit: env.rateLimitWindow
}
});
return res.status(status).json(body);
}
keyGenerator: req => req.rateLimitKey || generateHmac(getIP(req), ipSalt),
handler: handleRateExceeded
})

const apiLimiterStream = rateLimit({
const apiTunnelLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
max: env.rateLimitMax,
max: (req) => req.rateLimitMax || env.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => generateHmac(getIP(req), ipSalt),
keyGenerator: req => req.rateLimitKey || generateHmac(getIP(req), ipSalt),
handler: (req, res) => {
return res.sendStatus(429)
}
Expand All @@ -103,9 +110,6 @@ export const runAPI = (express, app, __dirname) => {
...corsConfig,
}));

app.post('/', apiLimiter);
app.use('/tunnel', apiLimiterStream);

app.post('/', (req, res, next) => {
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
Expand All @@ -117,7 +121,34 @@ export const runAPI = (express, app, __dirname) => {
});

app.post('/', (req, res, next) => {
if (!env.sessionEnabled) {
if (!env.apiKeyURL) {
return next();
}

const { success, error } = APIKeys.validateAuthorization(req);
if (!success) {
// We call next() here if either if:
// a) we have user sessions enabled, meaning the request
// will still need a Bearer token to not be rejected, or
// b) we do not require the user to be authenticated, and
// so they can just make the request with the regular
// rate limit configuration;
// otherwise, we reject the request.
if (
(env.sessionEnabled || !env.authRequired)
&& ['missing', 'not_api_key'].includes(error)
) {
return next();
}

return fail(res, `error.api.auth.key.${error}`);
}

return next();
});

app.post('/', (req, res, next) => {
if (!env.sessionEnabled || req.rateLimitKey) {
return next();
}

Expand All @@ -139,14 +170,16 @@ export const runAPI = (express, app, __dirname) => {
return fail(res, "error.api.auth.jwt.invalid");
}

req.authorized = true;
req.rateLimitKey = generateHmac(req.header("Authorization"), ipSalt);
} catch {
return fail(res, "error.api.generic");
}
next();
});

app.post('/', apiLimiter);
app.use('/', express.json({ limit: 1024 }));

app.use('/', (err, _, res, next) => {
if (err) {
const { status, body } = createResponse("error", {
Expand All @@ -158,7 +191,7 @@ export const runAPI = (express, app, __dirname) => {
next();
});

app.post("/session", async (req, res) => {
app.post("/session", sessionLimiter, async (req, res) => {
if (!env.sessionEnabled) {
return fail(res, "error.api.auth.not_configured")
}
Expand Down Expand Up @@ -228,7 +261,7 @@ export const runAPI = (express, app, __dirname) => {
}
})

app.get('/tunnel', (req, res) => {
app.get('/tunnel', apiTunnelLimiter, (req, res) => {
const id = String(req.query.id);
const exp = String(req.query.exp);
const sig = String(req.query.sig);
Expand Down Expand Up @@ -310,6 +343,10 @@ export const runAPI = (express, app, __dirname) => {
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}

if (env.apiKeyURL) {
APIKeys.setup(env.apiKeyURL);
}

app.listen(env.apiPort, env.listenAddress, () => {
console.log(`\n` +
Bright(Cyan("cobalt ")) + Bright("API ^ω⁠^") + "\n" +
Expand Down
7 changes: 7 additions & 0 deletions api/src/misc/console-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ function t(color, tt) {
export function Bright(tt) {
return t("\x1b[1m", tt)
}

export function Red(tt) {
return t("\x1b[31m", tt)
}

export function Green(tt) {
return t("\x1b[32m", tt)
}

export function Cyan(tt) {
return t("\x1b[36m", tt)
}

export function Yellow(tt) {
return t("\x1b[93m", tt)
}
Loading

0 comments on commit 4ed2df6

Please sign in to comment.