diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index fb93a8b4..4921507c 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -1,5 +1,8 @@ import express from 'express'; import passport from 'passport'; +import rateLimit from 'express-rate-limit'; + +import {contentTokenSchema} from '@shared/schema/api/torrents'; import type {FloodSettings} from '@shared/types/FloodSettings'; import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; @@ -12,6 +15,7 @@ import clientRoutes from './client'; import clientActivityStream from '../../middleware/clientActivityStream'; import eventStream from '../../middleware/eventStream'; import feedMonitorRoutes from './feed-monitor'; +import {getAuthToken, verifyToken} from '../../util/authUtil'; import {getDirectoryList} from '../../util/fileUtil'; import {getResponseFn} from '../../util/ajaxUtil'; import torrentsRoutes from './torrents'; @@ -20,6 +24,51 @@ const router = express.Router(); router.use('/auth', authRoutes); +// Special routes that may bypass authentication when conditions matched + +/** + * GET /api/torrents/{hash}/contents/{indices}/data + * @summary Gets downloaded data of contents of a torrent. Allows unauthenticated + * access if a valid content token is found in the query. + * @see torrents.ts + */ +router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>( + '/torrents/:hash/contents/:indices/data', + rateLimit({ + windowMs: 5 * 60 * 1000, + max: 60, + }), + async (req, _res, next) => { + const {token} = req.query; + + if (typeof token === 'string' && token !== '') { + const payload = await verifyToken(token).catch(() => undefined); + + if (payload != null) { + const parsedResult = contentTokenSchema.safeParse(payload); + + if (parsedResult.success) { + const {username, hash: authorizedHash, indices: authorizedIndices, iat} = parsedResult.data; + + if ( + typeof username === 'string' && + typeof authorizedHash === 'string' && + typeof authorizedIndices === 'string' + ) { + const {hash: requestedHash, indices: requestedIndices} = req.params; + + if (requestedHash === authorizedHash && requestedIndices === authorizedIndices) { + req.cookies = {jwt: getAuthToken(username, iat)}; + } + } + } + } + } + + next(); + }, +); + // All subsequent routes need authentication router.use('/', passport.authenticate('jwt', {session: false}), appendUserServices); diff --git a/server/routes/api/torrents.ts b/server/routes/api/torrents.ts index ac854adc..c54f7617 100644 --- a/server/routes/api/torrents.ts +++ b/server/routes/api/torrents.ts @@ -11,6 +11,7 @@ import tar, {Pack} from 'tar-fs'; import type { AddTorrentByFileOptions, AddTorrentByURLOptions, + ContentToken, SetTorrentsTagsOptions, } from '@shared/schema/api/torrents'; import type { @@ -35,6 +36,7 @@ import { import {accessDeniedError, fileNotFoundError, isAllowedPath, sanitizePath} from '../../util/fileUtil'; import {getResponseFn, validationError} from '../../util/ajaxUtil'; import {getTempPath} from '../../models/TemporaryStorage'; +import {getToken} from '../../util/authUtil'; const getDestination = async ( services: Express.Request['services'], @@ -670,7 +672,7 @@ router.patch<{hash: string}, unknown, SetTorrentContentsPropertiesOptions>('/:ha * @param {string} indices.path - 'all' or indices of selected contents separated by ',' * @return {object} 200 - contents archived in .tar - application/x-tar */ -router.get( +router.get<{hash: string; indices: string}, unknown, unknown, {token: string}>( '/:hash/contents/:indices/data', // This operation is resource-intensive // Limit each IP to 60 requests every 5 minutes @@ -681,6 +683,17 @@ router.get( (req, res) => { const {hash, indices: stringIndices} = req.params; + if (req.user != null && req.query.token == null) { + res.redirect( + `?token=${getToken({ + username: req.user.username, + hash, + indices: stringIndices, + })}`, + ); + return; + } + const selectedTorrent = req.services?.torrentService.getTorrent(hash); if (!selectedTorrent) { res.status(404).json({error: 'Torrent not found.'}); diff --git a/server/util/authUtil.ts b/server/util/authUtil.ts index 0f77de82..f8c9f996 100644 --- a/server/util/authUtil.ts +++ b/server/util/authUtil.ts @@ -1,6 +1,8 @@ import {CookieOptions} from 'express'; import jwt from 'jsonwebtoken'; +import type {AuthToken} from '@shared/schema/Auth'; + import config from '../../config'; const EXPIRATION_SECONDS = 60 * 60 * 24 * 7; // one week @@ -11,12 +13,21 @@ export const getCookieOptions = (): CookieOptions => ({ sameSite: 'strict', }); -export const getAuthToken = (username: string): string => - jwt.sign({username}, config.secret, { +export const getAuthToken = (username: string, iat?: number): string => { + const authTokenPayload: Partial = { + username, + }; + + if (iat != null) { + authTokenPayload.iat = iat; + } + + return jwt.sign(authTokenPayload, config.secret, { expiresIn: EXPIRATION_SECONDS, }); +}; -export const getToken = (payload: Record) => +export const getToken = >(payload: Omit) => jwt.sign(payload, config.secret, { expiresIn: EXPIRATION_SECONDS, }); diff --git a/shared/schema/api/torrents.ts b/shared/schema/api/torrents.ts index 9238b0ef..c5b1e802 100644 --- a/shared/schema/api/torrents.ts +++ b/shared/schema/api/torrents.ts @@ -1,4 +1,4 @@ -import {array, boolean, object, record, string} from 'zod'; +import {array, boolean, number, object, record, string} from 'zod'; import {noComma} from '../../util/regEx'; import type {infer as zodInfer} from 'zod'; @@ -62,3 +62,16 @@ export const setTorrentsTagsSchema = object({ }); export type SetTorrentsTagsOptions = zodInfer; + +// GET /api/torrents/{hash}/contents/{indices}/data +export const contentTokenSchema = object({ + username: string(), + hash: string(), + indices: string(), + // issued at + iat: number(), + // expiration + exp: number(), +}); + +export type ContentToken = zodInfer;