Skip to content

Commit

Permalink
Use JWT as SitePreview authorization (#1455)
Browse files Browse the repository at this point in the history
Allows moving authorization code into `@comet/cms-site`.
  • Loading branch information
fraxachun authored Dec 18, 2023
1 parent fce5a9b commit 8968cc4
Show file tree
Hide file tree
Showing 16 changed files with 351 additions and 208 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ API_PORT=4000
API_URL=http://localhost:$API_PORT
API_URL_INTERNAL=http://localhost:$API_PORT
CORS_ALLOWED_ORIGINS="^http:\/\/localhost:\d+,^http://192.168.\d+.\d+:80[0-9]{2}"
SITE_PREVIEW_SECRET=uxa4ZBJ4exw8jgq-qrh

# blob storage
BLOB_STORAGE_DRIVER="file"
Expand Down
12 changes: 5 additions & 7 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,6 @@ type FilenameResponse {
isOccupied: Boolean!
}

type SitePreviewHash {
timestamp: Float!
hash: String!
}

type Link implements DocumentInterface {
id: ID!
updatedAt: DateTime!
Expand Down Expand Up @@ -568,8 +563,7 @@ type Query {
pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]!
paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes!
pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability!
getSitePreviewHash: SitePreviewHash!
validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean!
getSitePreviewJwt(path: String!, previewData: PreviewData!): String!
redirects(scope: RedirectScopeInput!, query: String, type: RedirectGenerationType, active: Boolean, sortColumnName: String, sortDirection: SortDirection! = ASC): [Redirect!]! @deprecated(reason: "Use paginatedRedirects instead. Will be removed in the next version.")
paginatedRedirects(scope: RedirectScopeInput!, search: String, filter: RedirectFilter, sort: [RedirectSort!], offset: Int! = 0, limit: Int! = 25): PaginatedRedirects!
redirect(id: ID!): Redirect!
Expand Down Expand Up @@ -626,6 +620,10 @@ enum SlugAvailability {
Reserved
}

input PreviewData {
includeInvisible: Boolean!
}

input RedirectFilter {
generationType: StringFilter
source: StringFilter
Expand Down
3 changes: 1 addition & 2 deletions demo/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import { DbModule } from "@src/db/db.module";
import { LinksModule } from "@src/links/links.module";
import { PagesModule } from "@src/pages/pages.module";
import { PredefinedPage } from "@src/predefined-page/entities/predefined-page.entity";
import { randomBytes } from "crypto";
import { Request } from "express";

import { AuthModule } from "./auth/auth.module";
Expand Down Expand Up @@ -110,7 +109,7 @@ export class AppModule {
Documents: [Page, Link, PredefinedPage],
Scope: PageTreeNodeScope,
reservedPaths: ["/events"],
sitePreviewSecret: randomBytes(32).toString("hex"),
sitePreviewSecret: config.sitePreviewSecret,
}),
RedirectsModule.register({ customTargets: { news: NewsLinkBlock }, Scope: RedirectScope }),
BlobStorageModule.register({
Expand Down
1 change: 1 addition & 0 deletions demo/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) {
},
storageDirectoryPrefix: envVars.BLOB_STORAGE_DIRECTORY_PREFIX,
},
sitePreviewSecret: envVars.SITE_PREVIEW_SECRET,
};
}

Expand Down
3 changes: 3 additions & 0 deletions demo/api/src/config/environment-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,7 @@ export class EnvironmentVariables {
@ValidateIf((v) => v.DAM_STORAGE_DRIVER === "s3")
@IsString()
S3_BUCKET: string;

@IsString()
SITE_PREVIEW_SECRET: string;
}
2 changes: 1 addition & 1 deletion demo/site/src/pages/[[...path]].tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PreviewData } from "@comet/cms-site";
import { defaultLanguage, domain } from "@src/config";
import { GQLPage } from "@src/graphql.generated";
import NotFound404 from "@src/pages/404";
Expand All @@ -9,7 +10,6 @@ import { ParsedUrlQuery } from "querystring";
import * as React from "react";

import { GQLPagesQuery, GQLPagesQueryVariables, GQLPageTypeQuery, GQLPageTypeQueryVariables } from "./[[...path]].generated";
import { PreviewData } from "./api/preview";

type PageProps = GQLPage & {
documentType: string;
Expand Down
30 changes: 2 additions & 28 deletions demo/site/src/pages/api/preview.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,5 @@
import createGraphQLClient, { GraphQLClientOptions } from "@src/util/createGraphQLClient";
import { gql } from "graphql-request";

import { GQLValidateSitePreviewHashQuery, GQLValidateSitePreviewHashQueryVariables } from "./preview.generated";
import { handlePreviewApiRequest } from "@comet/cms-site";

export default async function handler(req, res) {
const data = await createGraphQLClient().request<GQLValidateSitePreviewHashQuery, GQLValidateSitePreviewHashQueryVariables>(
gql`
query ValidateSitePreviewHash($timestamp: Float!, $hash: String!) {
validateSitePreviewHash(timestamp: $timestamp, hash: $hash)
}
`,
{
timestamp: parseInt(req.query.timestamp),
hash: req.query.hash,
},
);
if (!data.validateSitePreviewHash) {
return res.status(401).json({ message: "Validation failed" });
}

const previewData: PreviewData = {
includeInvisiblePages: true,
includeInvisibleBlocks: req.query.includeInvisibleBlocks === "true",
previewDamUrls: true,
};
res.setPreviewData(previewData);
res.redirect(req.query.path ?? "/");
handlePreviewApiRequest(req, res);
}

export type PreviewData = GraphQLClientOptions;
20 changes: 7 additions & 13 deletions demo/site/src/util/createGraphQLClient.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { PreviewData } from "@comet/cms-site";
import { GraphQLClient } from "graphql-request";

export type GraphQLClientOptions = {
includeInvisiblePages: boolean;
includeInvisibleBlocks: boolean;
previewDamUrls: boolean;
};

const defaultOptions: GraphQLClientOptions = {
includeInvisiblePages: false,
includeInvisibleBlocks: false,
previewDamUrls: false,
};
export default function createGraphQLClient(options: Partial<GraphQLClientOptions> = {}): GraphQLClient {
const { includeInvisibleBlocks, includeInvisiblePages, previewDamUrls } = { ...defaultOptions, ...options };
export default function createGraphQLClient(previewData?: PreviewData): GraphQLClient {
const { includeInvisibleBlocks, includeInvisiblePages, previewDamUrls } = {
includeInvisiblePages: !!previewData,
includeInvisibleBlocks: previewData && previewData.includeInvisible,
previewDamUrls: !!previewData,
};

const headers: Record<string, string> = {};

Expand Down
25 changes: 11 additions & 14 deletions packages/admin/cms-admin/src/preview/site/SitePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { VisibilityToggle } from "../common/VisibilityToggle";
import { SitePrevewIFrameLocationMessage, SitePreviewIFrameMessageType } from "./iframebridge/SitePreviewIFrameMessage";
import { useSitePreviewIFrameBridge } from "./iframebridge/useSitePreviewIFrameBridge";
import { OpenLinkDialog } from "./OpenLinkDialog";
import { GQLGetSitePreviewHashQuery } from "./SitePreview.generated";
import { GQLGetSitePreviewJwtQuery } from "./SitePreview.generated";
import { ActionsContainer, LogoWrapper, Root, SiteInformation, SiteLink, SiteLinkWrapper } from "./SitePreview.sc";

//TODO v4 remove RouteComponentProps
Expand Down Expand Up @@ -99,28 +99,25 @@ function SitePreview({ resolvePath, logo = <CometColor sx={{ fontSize: 32 }} />
}
});

const { data, error, refetch } = useQuery<GQLGetSitePreviewHashQuery>(
const { data, error, refetch } = useQuery<GQLGetSitePreviewJwtQuery>(
gql`
query GetSitePreviewHash {
getSitePreviewHash {
timestamp
hash
}
query GetSitePreviewJwt($path: String!, $previewData: PreviewData!) {
getSitePreviewJwt(path: $path, previewData: $previewData)
}
`,
{
fetchPolicy: "network-only",
variables: {
path: initialPath,
previewData: {
includeInvisible: showOnlyVisible ? false : true,
},
},
},
);
if (error) throw new Error(error.message);
if (!data) return <></>;
const params = new URLSearchParams({
timestamp: data.getSitePreviewHash.timestamp.toString(),
hash: data.getSitePreviewHash.hash,
path: initialPath,
includeInvisibleBlocks: showOnlyVisible ? "false" : "true",
});
const initialPageUrl = `${siteConfig.url}/api/preview?${params.toString()}`;
const initialPageUrl = `${siteConfig.url}/api/preview?jwt=${data.getSitePreviewJwt}`;

return (
<Root>
Expand Down
12 changes: 5 additions & 7 deletions packages/api/cms-api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,6 @@ type FilenameResponse {
isOccupied: Boolean!
}

type SitePreviewHash {
timestamp: Float!
hash: String!
}

type PageTreeNode {
id: ID!
parentId: String
Expand Down Expand Up @@ -309,8 +304,7 @@ type Query {
pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability!
cronJobs: [CronJob!]!
currentUser: CurrentUser!
getSitePreviewHash: SitePreviewHash!
validateSitePreviewHash(timestamp: Float!, hash: String!): Boolean!
getSitePreviewJwt(path: String!, previewData: PreviewData!): String!
}

input RedirectScopeInput {
Expand Down Expand Up @@ -420,6 +414,10 @@ enum SlugAvailability {
Reserved
}

input PreviewData {
includeInvisible: Boolean!
}

type Mutation {
createBuilds(input: CreateBuildsInput!): Boolean!
createRedirect(scope: RedirectScopeInput! = {}, input: RedirectInput!): Redirect!
Expand Down
60 changes: 22 additions & 38 deletions packages/api/cms-api/src/page-tree/site-preview.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,40 @@
import { Inject } from "@nestjs/common";
import { Args, ArgsType, Field, ObjectType, Query, Resolver } from "@nestjs/graphql";
import { IsNumber, IsString } from "class-validator";
import { createHmac } from "crypto";
import { differenceInMinutes, getTime } from "date-fns";
import { Args, ArgsType, Field, InputType, Query, Resolver } from "@nestjs/graphql";
import { Type } from "class-transformer";
import { IsBoolean, IsString, ValidateNested } from "class-validator";
import jsonwebtoken from "jsonwebtoken";

import { SITE_PREVIEW_CONFIG } from "./page-tree.constants";

@ObjectType()
@ArgsType()
class SitePreviewHash {
@Field(() => Number)
@IsNumber()
timestamp: number;
@InputType()
export class PreviewData {
@Field(() => Boolean)
@IsBoolean()
includeInvisible: boolean;
}

@ArgsType()
class SitePreviewArgs {
@Field(() => String)
@IsString()
hash: string;
path: string;

@Field(() => PreviewData)
@ValidateNested()
@Type(() => PreviewData)
previewData: PreviewData;
}

export type SitePreviewConfig = {
secret: string;
};

@Resolver(() => SitePreviewHash)
@Resolver()
export class SitePreviewResolver {
constructor(@Inject(SITE_PREVIEW_CONFIG) private readonly config: SitePreviewConfig) {}

@Query(() => SitePreviewHash)
getSitePreviewHash(): SitePreviewHash {
const timestamp = this.getTimestamp();
return {
timestamp: timestamp,
hash: this.createHash(timestamp),
};
}

@Query(() => Boolean)
validateSitePreviewHash(@Args() args: SitePreviewHash): boolean {
if (differenceInMinutes(this.getTimestamp(), args.timestamp) > 5) {
return false;
}
return this.createHash(args.timestamp) === args.hash;
}

private getTimestamp() {
return getTime(Date.now());
}

private createHash(timestamp: number): string {
if (!timestamp) throw new Error("Timestamp is required");
return createHmac("sha256", this.config.secret)
.update(timestamp + this.config.secret)
.digest("hex");
@Query(() => String)
getSitePreviewJwt(@Args() args: SitePreviewArgs): string {
return jsonwebtoken.sign({ ...args }, this.config.secret, { expiresIn: 10 });
}
}
2 changes: 1 addition & 1 deletion packages/site/cms-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@types/jsonwebtoken": "^8.5.9",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^3.0.0",
"rimraf": "^3.0.0",
Expand All @@ -38,6 +37,7 @@
"@gitbeaker/node": "^34.0.0",
"@testing-library/react-hooks": "^8.0.0",
"@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^8.5.9",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/styled-components": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/site/cms-site/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export { useIFrameBridge } from "./iframebridge/useIFrameBridge";
export { isWithPreviewPropsData, withPreview, WithPreviewProps } from "./iframebridge/withPreview";
export type { ImageDimensions } from "./image/Image";
export { calculateInheritAspectRatio, generateImageUrl, getMaxDimensionsFromArea, Image, parseAspectRatio } from "./image/Image";
export { getAuthedUser, hasAuthedUser } from "./preview/auth";
export { BlockPreviewProvider } from "./preview/BlockPreviewProvider";
export { handlePreviewApiRequest, PreviewData } from "./preview/handlePreviewApiRequest";
export { usePreview } from "./preview/usePreview";
export { PreviewSkeleton } from "./previewskeleton/PreviewSkeleton";
export { sendSitePreviewIFrameMessage } from "./sitePreview/iframebridge/sendSitePreviewIFrameMessage";
Expand Down
52 changes: 0 additions & 52 deletions packages/site/cms-site/src/preview/auth.ts

This file was deleted.

Loading

0 comments on commit 8968cc4

Please sign in to comment.