From ac1ed45e4bd351bf54070d464b7bb48a153c6255 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 4 Sep 2019 21:23:59 +0000 Subject: [PATCH] [CORL-588] Download and token fixes (#2519) * fix: fixed up download handler to improve error handling * fix: fixed invalid expiry date being set * fix: nick found bug --- .../routes/download/Download/DownloadForm.tsx | 10 +- .../download/Download/DownloadRoute.tsx | 2 +- .../app/handlers/api/account/download.ts | 254 +++--------------- src/core/server/app/handlers/api/index.ts | 1 + .../server/app/handlers/api/user/download.ts | 54 ++++ .../server/app/handlers/api/user/index.ts | 1 + .../server/app/middleware/passport/index.ts | 22 +- src/core/server/app/router/api/account.ts | 10 +- src/core/server/app/router/api/index.ts | 2 + src/core/server/app/router/api/user.ts | 12 + src/core/server/services/jwt/index.ts | 18 +- .../services/users/download/download.ts | 247 +++++++++-------- .../server/services/users/download/index.ts | 2 + .../server/services/users/download/token.ts | 175 ++++++++++++ src/core/server/services/users/index.ts | 2 +- 15 files changed, 442 insertions(+), 370 deletions(-) create mode 100644 src/core/server/app/handlers/api/user/download.ts create mode 100644 src/core/server/app/handlers/api/user/index.ts create mode 100644 src/core/server/app/router/api/user.ts create mode 100644 src/core/server/services/users/download/index.ts create mode 100644 src/core/server/services/users/download/token.ts diff --git a/src/core/client/account/routes/download/Download/DownloadForm.tsx b/src/core/client/account/routes/download/Download/DownloadForm.tsx index 591bcec07..54a0ddaac 100644 --- a/src/core/client/account/routes/download/Download/DownloadForm.tsx +++ b/src/core/client/account/routes/download/Download/DownloadForm.tsx @@ -1,5 +1,5 @@ import { Localized } from "fluent-react/compat"; -import React, { FunctionComponent } from "react"; +import React, { FunctionComponent, useCallback, useState } from "react"; import { Button, HorizontalGutter } from "coral-ui/components"; @@ -10,6 +10,11 @@ interface Props { } const DownloadForm: FunctionComponent = ({ token }) => { + const [submitted, setSubmitted] = useState(false); + const onClick = useCallback(() => { + setSubmitted(true); + }, [setSubmitted]); + return (
= ({ token }) => { type="submit" variant="filled" color="primary" - fullWidth={false} + disabled={submitted} + onClick={onClick} className={styles.downloadButton} > Download My Comment History diff --git a/src/core/client/account/routes/download/Download/DownloadRoute.tsx b/src/core/client/account/routes/download/Download/DownloadRoute.tsx index a31adc71f..fe3fa3c38 100644 --- a/src/core/client/account/routes/download/Download/DownloadRoute.tsx +++ b/src/core/client/account/routes/download/Download/DownloadRoute.tsx @@ -17,7 +17,7 @@ import styles from "./DownloadRoute.css"; const fetcher = createFetch( "downloadToken", async (environment: Environment, variables: { token: string }, { rest }) => - await rest.fetch("/account/downloadcheck", { + await rest.fetch("/account/download", { method: "GET", token: variables.token, }) diff --git a/src/core/server/app/handlers/api/account/download.ts b/src/core/server/app/handlers/api/account/download.ts index 366bdd2bf..0b6b18aaf 100644 --- a/src/core/server/app/handlers/api/account/download.ts +++ b/src/core/server/app/handlers/api/account/download.ts @@ -1,173 +1,38 @@ -import archiver from "archiver"; -import stringify from "csv-stringify"; -import DataLoader from "dataloader"; -import { Response } from "express"; import Joi from "joi"; -import { kebabCase } from "lodash"; -import { Db } from "mongodb"; import { AppOptions } from "coral-server/app"; import { validate } from "coral-server/app/request/body"; import { RequestLimiter } from "coral-server/app/request/limiter"; -import { getLatestRevision } from "coral-server/models/comment"; -import { Comment } from "coral-server/models/comment"; -import { retrieveManyStories } from "coral-server/models/story"; -import { Tenant } from "coral-server/models/tenant"; -import { User } from "coral-server/models/user"; -import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; -import { verifyDownloadTokenString } from "coral-server/services/users/download/download"; -import { RequestHandler } from "coral-server/types/express"; -import { Request } from "coral-server/types/express"; -const BATCH_SIZE = 100; +import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; +import { + redeemDownloadToken, + sendUserDownload, + verifyDownloadTokenString, +} from "coral-server/services/users/download"; +import { RequestHandler } from "coral-server/types/express"; + const USER_ID_LIMITER_TTL = "1d"; -export type DownloadOptions = Pick< +export type AccountDownloadOptions = Pick< AppOptions, "mongo" | "redis" | "signingConfig" | "config" >; -export type AdminDownloadOptions = Pick; - -async function sendExport( - mongo: Db, - tenant: Tenant, - user: Readonly, - latestContentDate: Date, - res: Response -) { - // Create the date formatter to format the dates for the CSV. - const formatter = Intl.DateTimeFormat(tenant.locale, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: false, - }); - - // Create a DataLoader to load stories for each batch. - const getStories = new DataLoader((ids: string[]) => - retrieveManyStories(mongo, tenant.id, ids) - ); - - // Create a cursor to iterate over the user's comment's in order. - const cursor = mongo - .collection>("comments") - .find({ - tenantID: tenant.id, - authorID: user.id, - createdAt: { - $lt: latestContentDate, - }, - }) - .sort({ createdAt: 1 }); - - // Collect all the user's comments in batches. - let commentBatch: Array> = []; - - // Generate the filename of the file that the user will download. - const filename = `talk-${kebabCase(user.username)}-${kebabCase( - formatter.format(latestContentDate) - )}.zip`; - - res.writeHead(200, { - "Content-Type": "application/octet-stream", - "Content-Disposition": `attachment; filename=${filename}`, - }); - - // Create the zip archive we'll use to write all the exported files to. - const archive = archiver("zip", { - zlib: { level: 9 }, - }); - - // Pipe this to the response writer directly. - archive.pipe(res); - - // Create all the csv writers that'll write the data to the archive. - const csv = stringify(); - - // Add all the streams as files to the archive. - archive.append(csv, { name: "comments-export/my_comments.csv" }); - - csv.write([ - "Comment ID", - "Published Timestamp", - "Article URL", - "Comment URL", - "Comment Text", - ]); - - /** - * writeAndFlushBatch will write the given batch of comments to the CSV and - * flush out the batchfor the next run. - */ - const writeAndFlushBatch = async () => { - const stories = await getStories.loadMany( - commentBatch.map(({ storyID }) => storyID) - ); - - for (let i = 0; i < commentBatch.length; i++) { - const comment = commentBatch[i]; - const story = stories[i]; - if (!story) { - continue; - } - - const revision = getLatestRevision(comment); - - const commentID = comment.id; - const createdAt = formatter.format(new Date(comment.createdAt)); - const storyURL = story.url; - const commentURL = `${storyURL}?commentID=${commentID}`; - const body = revision.body; - - csv.write([commentID, createdAt, storyURL, commentURL, body]); - } - - commentBatch = []; - }; - - while (await cursor.hasNext()) { - const comment = await cursor.next(); - if (!comment) { - break; - } - - commentBatch.push(comment); - - if (commentBatch.length >= BATCH_SIZE) { - await writeAndFlushBatch(); - } - } - - if (commentBatch.length > 0) { - await writeAndFlushBatch(); - } - - csv.end(); - - // Mark the end of adding files, no more files can be added after this. Once - // all the stream readers have finished writing, and have closed, the - // archiver will close which will finish the HTTP request. - await archive.finalize(); -} - -export interface DownloadBody { +export interface AccountDownloadBody { token: string; } -export const DownloadBodySchema = Joi.object().keys({ +export const AccountDownloadBodySchema = Joi.object().keys({ token: Joi.string().trim(), }); -export const downloadHandler = ({ +export const accountDownloadHandler = ({ mongo, redis, signingConfig, config, -}: DownloadOptions): RequestHandler => { +}: AccountDownloadOptions): RequestHandler => { const userIDLimiter = new RequestLimiter({ redis, ttl: USER_ID_LIMITER_TTL, @@ -176,29 +41,33 @@ export const downloadHandler = ({ config, }); - return async (req: Request, res, next) => { - // Tenant is guaranteed at this point. - const coral = req.coral!; - const tenant = coral.tenant!; - - // Get the fields from the body. Validate will throw an error if the body - // does not conform to the specification. - const { token }: DownloadBody = validate(DownloadBodySchema, req.body); - - // Decode the token so we can rate limit based on the user's ID. - const { sub: userID } = decodeJWT(token); - if (!userID) { - return res.sendStatus(400); - } - - await userIDLimiter.test(req, userID); - + return async (req, res, next) => { try { + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { token }: AccountDownloadBody = validate( + AccountDownloadBodySchema, + req.body + ); + + // Decode the token so we can rate limit based on the user's ID. + const { sub: userID } = decodeJWT(token); + if (!userID) { + return res.sendStatus(400); + } + + await userIDLimiter.test(req, userID); + const { token: { iat }, user, - } = await verifyDownloadTokenString( + } = await redeemDownloadToken( mongo, + redis, tenant, signingConfig, token, @@ -209,7 +78,7 @@ export const downloadHandler = ({ const latestContentDate = new Date(iat * 1000); // Send the export down the response. - await sendExport(mongo, tenant, user, latestContentDate, res); + await sendUserDownload(res, mongo, tenant, user, latestContentDate); return; } catch (err) { @@ -218,61 +87,21 @@ export const downloadHandler = ({ }; }; -export const adminDownloadHandler = ({ - mongo, - signingConfig, -}: AdminDownloadOptions): RequestHandler => { - return async (req: Request, res, next) => { - // Tenant is guaranteed at this point. - const coral = req.coral!; - const tenant = coral.tenant!; - const { token } = req.query; - - const { sub: userID } = decodeJWT(token); - if (!userID) { - return res.sendStatus(400); - } - - try { - const { - token: { iat }, - user, - } = await verifyDownloadTokenString( - mongo, - tenant, - signingConfig, - token, - coral.now - ); - - // Only load comments since this download token was issued. - const latestContentDate = new Date(iat * 1000); - - // Send the export down the response. - await sendExport(mongo, tenant, user, latestContentDate, res); - - return; - } catch (err) { - return next(err); - } - }; -}; - -export type DownloadCheckOptions = Pick< +export type AccountDownloadCheckOptions = Pick< AppOptions, "mongo" | "redis" | "signingConfig" | "config" >; -export const downloadCheckHandler = ({ +export const accountDownloadCheckHandler = ({ mongo, redis, signingConfig, config, -}: DownloadCheckOptions): RequestHandler => { +}: AccountDownloadCheckOptions): RequestHandler => { const userIDLimiter = new RequestLimiter({ redis, - ttl: USER_ID_LIMITER_TTL, - max: 1, + ttl: "10m", + max: 10, prefix: "userID", config, }); @@ -297,6 +126,7 @@ export const downloadCheckHandler = ({ await verifyDownloadTokenString( mongo, + redis, tenant, signingConfig, tokenString, diff --git a/src/core/server/app/handlers/api/index.ts b/src/core/server/app/handlers/api/index.ts index 77483fb81..1c994d535 100644 --- a/src/core/server/app/handlers/api/index.ts +++ b/src/core/server/app/handlers/api/index.ts @@ -4,3 +4,4 @@ export * from "./graphql"; export * from "./health"; export * from "./install"; export * from "./version"; +export * from "./user"; diff --git a/src/core/server/app/handlers/api/user/download.ts b/src/core/server/app/handlers/api/user/download.ts new file mode 100644 index 000000000..e74c5a88a --- /dev/null +++ b/src/core/server/app/handlers/api/user/download.ts @@ -0,0 +1,54 @@ +import { AppOptions } from "coral-server/app"; +import { decodeJWT } from "coral-server/services/jwt"; +import { + redeemDownloadToken, + sendUserDownload, +} from "coral-server/services/users/download"; +import { RequestHandler } from "coral-server/types/express"; + +type AdminDownloadOptions = Pick< + AppOptions, + "mongo" | "redis" | "signingConfig" +>; + +export const userDownloadHandler = ({ + mongo, + redis, + signingConfig, +}: AdminDownloadOptions): RequestHandler => { + return async (req, res, next) => { + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + const { token } = req.query; + + const { sub: userID } = decodeJWT(token); + if (!userID) { + return res.sendStatus(400); + } + + try { + const { + token: { iat }, + user, + } = await redeemDownloadToken( + mongo, + redis, + tenant, + signingConfig, + token, + coral.now + ); + + // Only load comments since this download token was issued. + const latestContentDate = new Date(iat * 1000); + + // Send the export down the response. + await sendUserDownload(res, mongo, tenant, user, latestContentDate); + + return; + } catch (err) { + return next(err); + } + }; +}; diff --git a/src/core/server/app/handlers/api/user/index.ts b/src/core/server/app/handlers/api/user/index.ts new file mode 100644 index 000000000..3adcf5a88 --- /dev/null +++ b/src/core/server/app/handlers/api/user/index.ts @@ -0,0 +1 @@ +export * from "./download"; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 7da5288b1..a5b691dd3 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -91,17 +91,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) { // Grab the JTI from the decoded token. const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded); if (jti && exp) { - // Compute the number of seconds that the token will be valid for. - const validFor = Math.round( - DateTime.fromJSDate(now) - .plus({ seconds: -exp }) - .toSeconds() - ); - if (validFor > 0) { - // Invalidate the token, the expiry is in the future and it needs to be - // revoked. - await revokeJWT(redis, jti, validFor, now); - } + // Invalidate the token, the expiry is in the future and it needs to be + // revoked. + await revokeJWT(redis, jti, exp, now); } // Clear the cookie. @@ -132,9 +124,7 @@ export async function handleSuccessfulLogin( signingConfig, user, tenant, - { - expiresIn: Math.round(expiresIn.toSeconds()), - }, + { expiresIn: "1d" }, coral.now ); @@ -194,9 +184,7 @@ export async function handleOAuth2Callback( signingConfig, user, tenant, - { - expiresIn: Math.round(expiresIn.toSeconds()), - }, + { expiresIn: "1d" }, req.coral!.now ); res.cookie( diff --git a/src/core/server/app/router/api/account.ts b/src/core/server/app/router/api/account.ts index 1b59eb26a..c924b2138 100644 --- a/src/core/server/app/router/api/account.ts +++ b/src/core/server/app/router/api/account.ts @@ -3,12 +3,11 @@ import express from "express"; import { AppOptions } from "coral-server/app"; import { - adminDownloadHandler, + accountDownloadCheckHandler, + accountDownloadHandler, confirmCheckHandler, confirmHandler, confirmRequestHandler, - downloadCheckHandler, - downloadHandler, inviteCheckHandler, inviteHandler, } from "coral-server/app/handlers"; @@ -34,14 +33,13 @@ export function createNewAccountRouter( router.get("/invite", inviteCheckHandler(app)); router.put("/invite", jsonMiddleware, inviteHandler(app)); - router.get("/downloadcheck", downloadCheckHandler(app)); - router.get("/download", adminDownloadHandler(app)); + router.get("/download", accountDownloadCheckHandler(app)); router.post( "/download", bodyParser.urlencoded({ extended: true, }), - downloadHandler(app) + accountDownloadHandler(app) ); return router; diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts index bdf778c9e..8b040bfa5 100644 --- a/src/core/server/app/router/api/index.ts +++ b/src/core/server/app/router/api/index.ts @@ -18,6 +18,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant"; import { createNewAccountRouter } from "./account"; import { createNewAuthRouter } from "./auth"; +import { createNewUserRouter } from "./user"; export interface RouterOptions { /** @@ -55,6 +56,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) { // Create the auth router. router.use("/auth", createNewAuthRouter(app, options)); router.use("/account", createNewAccountRouter(app, options)); + router.use("/user", createNewUserRouter(app)); // Configure the GraphQL route. router.use( diff --git a/src/core/server/app/router/api/user.ts b/src/core/server/app/router/api/user.ts new file mode 100644 index 000000000..837749b35 --- /dev/null +++ b/src/core/server/app/router/api/user.ts @@ -0,0 +1,12 @@ +import express from "express"; + +import { AppOptions } from "coral-server/app"; +import { userDownloadHandler } from "coral-server/app/handlers"; + +export function createNewUserRouter(app: AppOptions) { + const router = express.Router(); + + router.get("/download", userDownloadHandler(app)); + + return router; +} diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 614101259..4a273e72f 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -354,20 +354,26 @@ function generateJTIRevokedKey(jti: string) { * * @param redis the Redis instance to revoke the JWT with * @param jti the JTI claim of the JWT token being revoked - * @param validFor number of seconds that the token was valid for + * @param exp time that the token expired at * @param now the current date */ export async function revokeJWT( redis: Redis, jti: string, - validFor: number, + exp: number, now = new Date() ) { - await redis.setex( - generateJTIRevokedKey(jti), - Math.round(validFor), - Math.round(DateTime.fromJSDate(now).toSeconds()) + const validFor = Math.round( + DateTime.fromSeconds(exp).diff(DateTime.fromJSDate(now), "seconds").seconds ); + + if (validFor > 0) { + await redis.setex( + generateJTIRevokedKey(jti), + validFor, + Math.round(DateTime.fromJSDate(now).toSeconds()) + ); + } } /** diff --git a/src/core/server/services/users/download/download.ts b/src/core/server/services/users/download/download.ts index fd2f4f55d..195bb5a01 100644 --- a/src/core/server/services/users/download/download.ts +++ b/src/core/server/services/users/download/download.ts @@ -1,142 +1,139 @@ -import Joi from "joi"; -import { isNull } from "lodash"; -import { DateTime } from "luxon"; +import archiver from "archiver"; +import stringify from "csv-stringify"; +import DataLoader from "dataloader"; +import { Response } from "express"; +import { kebabCase } from "lodash"; import { Db } from "mongodb"; -import uuid from "uuid"; -import { constructTenantURL } from "coral-server/app/url"; -import { Config } from "coral-server/config"; -import { TokenInvalidError, UserNotFoundError } from "coral-server/errors"; +import { getLatestRevision } from "coral-server/models/comment"; +import { Comment } from "coral-server/models/comment"; +import { retrieveManyStories } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; -import { retrieveUser } from "coral-server/models/user"; -import { - JWTSigningConfig, - signString, - StandardClaims, - StandardClaimsSchema, - verifyJWT, -} from "coral-server/services/jwt"; +import { User } from "coral-server/models/user"; -interface DownloadToken extends Required { - aud: "download"; -} +const BATCH_SIZE = 100; -const DownloadTokenSchema = StandardClaimsSchema.keys({ - aud: Joi.string().only("download"), -}); - -export async function generateDownloadToken( - userID: string, - tenant: Tenant, - config: Config, - signingConfig: JWTSigningConfig, - now: Date -) { - const nowDate = DateTime.fromJSDate(now); - const nowSeconds = Math.round(nowDate.toSeconds()); - const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds()); - - const downloadToken: DownloadToken = { - jti: uuid.v4(), - iss: tenant.id, - sub: userID, - exp: expiresAt, - iat: nowSeconds, - nbf: nowSeconds, - aud: "download", - }; - - return await signString(signingConfig, downloadToken); -} - -export async function generateDownloadLink( - userID: string, - tenant: Tenant, - config: Config, - signingConfig: JWTSigningConfig, - now: Date -) { - const token = await generateDownloadToken( - userID, - tenant, - config, - signingConfig, - now - ); - - return constructTenantURL( - config, - tenant, - `/account/download#downloadToken=${token}` - ); -} - -export async function generateAdminDownloadLink( - userID: string, - tenant: Tenant, - config: Config, - signingConfig: JWTSigningConfig, - now: Date -) { - const token = await generateDownloadToken( - userID, - tenant, - config, - signingConfig, - now - ); - - return constructTenantURL( - config, - tenant, - `/api/account/download?token=${token}` - ); -} - -export function validateDownloadToken( - token: DownloadToken | object -): Error | null { - const { error } = Joi.validate(token, DownloadTokenSchema, { - presence: "required", - }); - return error || null; -} - -export function isDownloadToken( - token: DownloadToken | object -): token is DownloadToken { - return isNull(validateDownloadToken(token)); -} - -export async function verifyDownloadTokenString( +export async function sendUserDownload( + res: Response, mongo: Db, tenant: Tenant, - signingConfig: JWTSigningConfig, - tokenString: string, - now: Date + user: Readonly, + latestContentDate: Date ) { - const token = verifyJWT(tokenString, signingConfig, now, { - issuer: tenant.id, - audience: "download", + // Create the date formatter to format the dates for the CSV. + const formatter = Intl.DateTimeFormat(tenant.locale, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, }); - if (!isDownloadToken(token)) { - throw new TokenInvalidError( - tokenString, - "does not conform to the download token schema" + // Create a DataLoader to load stories for each batch. + const getStories = new DataLoader((ids: string[]) => + retrieveManyStories(mongo, tenant.id, ids) + ); + + // Create a cursor to iterate over the user's comment's in order. + const cursor = mongo + .collection>("comments") + .find({ + tenantID: tenant.id, + authorID: user.id, + createdAt: { + $lt: latestContentDate, + }, + }) + .sort({ createdAt: 1 }); + + // Collect all the user's comments in batches. + let commentBatch: Array> = []; + + // Generate the filename of the file that the user will download. + const filename = `talk-${kebabCase(user.username)}-${kebabCase( + formatter.format(latestContentDate) + )}.zip`; + + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename=${filename}`, + }); + + // Create the zip archive we'll use to write all the exported files to. + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + // Pipe this to the response writer directly. + archive.pipe(res); + + // Create all the csv writers that'll write the data to the archive. + const csv = stringify(); + + // Add all the streams as files to the archive. + archive.append(csv, { name: "comments-export/my_comments.csv" }); + + csv.write([ + "Comment ID", + "Published Timestamp", + "Article URL", + "Comment URL", + "Comment Text", + ]); + + /** + * writeAndFlushBatch will write the given batch of comments to the CSV and + * flush out the batchfor the next run. + */ + const writeAndFlushBatch = async () => { + const stories = await getStories.loadMany( + commentBatch.map(({ storyID }) => storyID) ); + + for (let i = 0; i < commentBatch.length; i++) { + const comment = commentBatch[i]; + const story = stories[i]; + if (!story) { + continue; + } + + const revision = getLatestRevision(comment); + + const commentID = comment.id; + const createdAt = formatter.format(new Date(comment.createdAt)); + const storyURL = story.url; + const commentURL = `${storyURL}?commentID=${commentID}`; + const body = revision.body; + + csv.write([commentID, createdAt, storyURL, commentURL, body]); + } + + commentBatch = []; + }; + + while (await cursor.hasNext()) { + const comment = await cursor.next(); + if (!comment) { + break; + } + + commentBatch.push(comment); + + if (commentBatch.length >= BATCH_SIZE) { + await writeAndFlushBatch(); + } } - const { sub: userID, iss } = token; - - const user = await retrieveUser(mongo, tenant.id, userID); - if (!user) { - throw new UserNotFoundError(userID); + if (commentBatch.length > 0) { + await writeAndFlushBatch(); } - if (iss !== tenant.id) { - throw new TokenInvalidError(tokenString, "invalid tenant"); - } + csv.end(); - return { token, user }; + // Mark the end of adding files, no more files can be added after this. Once + // all the stream readers have finished writing, and have closed, the + // archiver will close which will finish the HTTP request. + await archive.finalize(); } diff --git a/src/core/server/services/users/download/index.ts b/src/core/server/services/users/download/index.ts new file mode 100644 index 000000000..490cba42a --- /dev/null +++ b/src/core/server/services/users/download/index.ts @@ -0,0 +1,2 @@ +export * from "./download"; +export * from "./token"; diff --git a/src/core/server/services/users/download/token.ts b/src/core/server/services/users/download/token.ts new file mode 100644 index 000000000..ee475257b --- /dev/null +++ b/src/core/server/services/users/download/token.ts @@ -0,0 +1,175 @@ +import { Redis } from "ioredis"; +import Joi from "joi"; +import { isNull } from "lodash"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { constructTenantURL } from "coral-server/app/url"; +import { Config } from "coral-server/config"; +import { TokenInvalidError, UserNotFoundError } from "coral-server/errors"; +import { Tenant } from "coral-server/models/tenant"; +import { retrieveUser } from "coral-server/models/user"; +import { + isJWTRevoked, + JWTSigningConfig, + revokeJWT, + signString, + StandardClaims, + StandardClaimsSchema, + verifyJWT, +} from "coral-server/services/jwt"; + +interface DownloadToken extends Required { + aud: "download"; +} + +const DownloadTokenSchema = StandardClaimsSchema.keys({ + aud: Joi.string().only("download"), +}); + +export async function generateDownloadToken( + userID: string, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + now: Date +) { + const nowDate = DateTime.fromJSDate(now); + const nowSeconds = Math.round(nowDate.toSeconds()); + const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds()); + + const downloadToken: DownloadToken = { + jti: uuid.v4(), + iss: tenant.id, + sub: userID, + exp: expiresAt, + iat: nowSeconds, + nbf: nowSeconds, + aud: "download", + }; + + return await signString(signingConfig, downloadToken); +} + +export async function generateDownloadLink( + userID: string, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + now: Date +) { + const token = await generateDownloadToken( + userID, + tenant, + config, + signingConfig, + now + ); + + return constructTenantURL( + config, + tenant, + `/account/download#downloadToken=${token}` + ); +} + +export async function generateAdminDownloadLink( + userID: string, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + now: Date +) { + const token = await generateDownloadToken( + userID, + tenant, + config, + signingConfig, + now + ); + + return constructTenantURL( + config, + tenant, + `/api/user/download?token=${token}` + ); +} + +export function validateDownloadToken( + token: DownloadToken | object +): Error | null { + const { error } = Joi.validate(token, DownloadTokenSchema, { + presence: "required", + }); + return error || null; +} + +export function isDownloadToken( + token: DownloadToken | object +): token is DownloadToken { + return isNull(validateDownloadToken(token)); +} + +export async function verifyDownloadTokenString( + mongo: Db, + redis: Redis, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + const token = verifyJWT(tokenString, signingConfig, now, { + issuer: tenant.id, + audience: "download", + }); + + if (!isDownloadToken(token)) { + throw new TokenInvalidError( + tokenString, + "does not conform to the download token schema" + ); + } + + const { sub: userID, iss } = token; + + const user = await retrieveUser(mongo, tenant.id, userID); + if (!user) { + throw new UserNotFoundError(userID); + } + + if (iss !== tenant.id) { + throw new TokenInvalidError(tokenString, "invalid tenant"); + } + + // Check to see if the token was revoked. + if (await isJWTRevoked(redis, token.jti)) { + throw new TokenInvalidError(tokenString, "token was revoked"); + } + + return { token, user }; +} + +export async function redeemDownloadToken( + mongo: Db, + redis: Redis, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + // Verify the download token. + const { token, user } = await verifyDownloadTokenString( + mongo, + redis, + tenant, + signingConfig, + tokenString, + now + ); + + // Revoke the download token. + await revokeJWT(redis, token.jti, token.exp, now); + + return { token, user }; +} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 791779a10..9212301b4 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -71,7 +71,7 @@ import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; import { generateAdminDownloadLink, generateDownloadLink, -} from "./download/download"; +} from "./download/token"; import { validateEmail, validatePassword, validateUsername } from "./helpers"; export type InsertUser = InsertUserInput;