diff --git a/ccm_web/client/src/App.tsx b/ccm_web/client/src/App.tsx index d825c3c45..e37b4507c 100644 --- a/ccm_web/client/src/App.tsx +++ b/ccm_web/client/src/App.tsx @@ -20,7 +20,7 @@ function App (): JSX.Element { const location = useLocation() - const [globals, isAuthenticated, isLoading, globalsError, csrfTokenCookieError] = useGlobals() + const [globals, csrfToken, isAuthenticated, isLoading, globalsError, csrfTokenCookieError] = useGlobals() const [course, setCourse] = useState(undefined) const [doLoadCourse, isCourseLoading, getCourseError] = usePromise( @@ -42,7 +42,7 @@ function App (): JSX.Element { if (globalsError !== undefined) console.error(globalsError) if (csrfTokenCookieError !== undefined) console.error(csrfTokenCookieError) - if (globals === undefined || !isAuthenticated) { + if (globals === undefined || !isAuthenticated || csrfToken === undefined) { redirect('/access-denied') return (loading) } @@ -71,16 +71,17 @@ function App (): JSX.Element { : undefined return ( - + - + {features.map(feature => { return ( Cookies.get('CSRF-Token') - -const initCSRFRequest = (headers: Array<[string, string]>): RequestInit => { - const csrfToken = getCSRFToken() - if (csrfToken !== undefined) headers.push(['CSRF-Token', csrfToken]) +const addStateChangeCallHeaders = (csrfToken: string): RequestInit => { + const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType], ['x-csrf-token', csrfToken]] const request: RequestInit = { headers } return request } @@ -24,26 +20,23 @@ const getGet = (): RequestInit => { return request } -const getPost = (body: string): RequestInit => { - const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]] - const request = initCSRFRequest(headers) +const getPost = (body: string, csrfToken: string): RequestInit => { + const request = addStateChangeCallHeaders(csrfToken) request.method = 'POST' request.body = body return request } -const getDelete = (body: string): RequestInit => { - const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]] - const request = initCSRFRequest(headers) +const getDelete = (body: string, csrfToken: string): RequestInit => { + const request = addStateChangeCallHeaders(csrfToken) request.method = 'DELETE' request.body = body return request } // This currently assumes all put requests have a JSON payload and receive a JSON response. -const getPut = (body: string): RequestInit => { - const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]] - const request = initCSRFRequest(headers) +const getPut = (body: string, csrfToken: string): RequestInit => { + const request = addStateChangeCallHeaders(csrfToken) request.method = 'PUT' request.body = body return request @@ -56,8 +49,8 @@ export const getCourse = async (courseId: number): Promise => return await resp.json() } -export const setCourseName = async (courseId: number, newName: string): Promise => { - const request = getPut(JSON.stringify({ newName: newName })) +export const setCourseName = async (courseId: number, newName: string, csrfToken: string): Promise => { + const request = getPut(JSON.stringify({ newName: newName }), csrfToken) const resp = await fetch(`/api/course/${courseId}/name`, request) await handleErrors(resp) return await resp.json() @@ -77,9 +70,9 @@ export const getCourseSections = async (courseId: number): Promise => { +export const addCourseSections = async (courseId: number, sectionNames: string[], csrfToken: string): Promise => { const body = JSON.stringify({ sections: sectionNames }) - const request = getPost(body) + const request = getPost(body, csrfToken) const resp = await fetch('/api/course/' + courseId.toString() + '/sections', request) await handleErrors(resp) return await resp.json() @@ -102,27 +95,28 @@ export const getStudentsEnrolledInSection = async (sectionId: number): Promise => { const body = JSON.stringify({ users: enrollments }) - const request = getPost(body) + const request = getPost(body, csrfToken) const resp = await fetch(`/api/sections/${sectionId}/enroll`, request) await handleErrors(resp) return await resp.json() } -export const addEnrollmentsToSections = async (enrollments: AddEnrollmentWithSectionId[]): Promise => { +export const addEnrollmentsToSections = async (enrollments: AddEnrollmentWithSectionId[], csrfToken: string): Promise => { const body = JSON.stringify({ enrollments }) - const request = getPost(body) + const request = getPost(body, csrfToken) const resp = await fetch('/api/sections/enroll', request) await handleErrors(resp) return await resp.json() } -export const setCSRFTokenCookie = async (): Promise => { +export const getCSRFTokenResponse = async (): Promise => { const request = getGet() const resp = await fetch('/auth/csrfToken', request) await handleErrors(resp) + return await resp.json() } export const getTeacherSections = async (termId: number): Promise => { @@ -140,17 +134,17 @@ export const searchSections = async (termId: number, searchType: 'uniqname' | 'c return await resp.json() } -export const mergeSections = async (courseId: number, sectionsToMerge: CanvasCourseSection[]): Promise => { +export const mergeSections = async (courseId: number, sectionsToMerge: CanvasCourseSection[], csrfToken: string): Promise => { const body = JSON.stringify({ sectionIds: sectionsToMerge.map(section => { return section.id }) }) - const request = getPost(body) + const request = getPost(body, csrfToken) const resp = await fetch(`/api/course/${courseId}/sections/merge`, request) await handleErrors(resp) return await resp.json() } -export const unmergeSections = async (sectionsToUnmerge: CanvasCourseSection[]): Promise => { +export const unmergeSections = async (sectionsToUnmerge: CanvasCourseSection[], csrfToken: string): Promise => { const body = JSON.stringify({ sectionIds: sectionsToUnmerge.map(section => { return section.id }) }) - const request = getDelete(body) + const request = getDelete(body, csrfToken) const resp = await fetch('/api/sections/unmerge', request) await handleErrors(resp) return await resp.json() @@ -181,9 +175,9 @@ interface ExternalUser { givenName: string } -export const createExternalUsers = async (newUsers: ExternalUser[]): Promise => { +export const createExternalUsers = async (newUsers: ExternalUser[], csrfToken: string): Promise => { const body = JSON.stringify({ users: newUsers }) - const request = getPost(body) + const request = getPost(body, csrfToken) const resp = await fetch('/api/admin/createExternalUsers', request) await handleErrors(resp) return await resp.json() diff --git a/ccm_web/client/src/components/CourseSectionList.tsx b/ccm_web/client/src/components/CourseSectionList.tsx index f0e0d8e2b..ffc776af0 100644 --- a/ccm_web/client/src/components/CourseSectionList.tsx +++ b/ccm_web/client/src/components/CourseSectionList.tsx @@ -89,7 +89,7 @@ function CourseSectionList (props: CourseSectionListProps): JSX.Element { const [sectionsToUnmerge, setSectionsToUnmerge] = useState([]) const [doUnmerge, isUnmerging, unmergeError] = usePromise( - async () => await unmergeSections(sectionsToUnmerge), + async () => await unmergeSections(sectionsToUnmerge, props.csrfToken.token), (unmergedSections: CanvasCourseSectionBase[]) => { setSections(sections.filter(section => { return !unmergedSections.map(s => { return s.id }).includes(section.id) })) setSectionsToUnmerge([]) diff --git a/ccm_web/client/src/components/CreateSectionWidget.tsx b/ccm_web/client/src/components/CreateSectionWidget.tsx index a3bbf10d9..9192024cd 100644 --- a/ccm_web/client/src/components/CreateSectionWidget.tsx +++ b/ccm_web/client/src/components/CreateSectionWidget.tsx @@ -8,6 +8,7 @@ import APIErrorMessage from './APIErrorMessage' import { addCourseSections } from '../api' import { CanvasCourseBase, CanvasCourseSection } from '../models/canvas' import { CanvasCoursesSectionNameValidator, ICanvasSectionNameInvalidError } from '../utils/canvasSectionNameValidator' +import { CsrfToken } from '../models/models' const PREFIX = 'CreateSectionWidget' @@ -39,6 +40,7 @@ const Root = styled('div')(( export interface CreateSectionWidgetProps { course: CanvasCourseBase + csrfToken: CsrfToken onSectionCreated: (newSection: CanvasCourseSection) => void } @@ -66,7 +68,7 @@ function CreateSectionWidget (props: CreateSectionWidgetProps): JSX.Element { setIsCreating(true) nameValidator.validateSectionName(newSectionName).then(errors => { if (errors.length === 0) { - addCourseSections(props.course.id, [newSectionName]) + addCourseSections(props.course.id, [newSectionName], props.csrfToken.token) .then(newSections => { props.onSectionCreated(newSections[0]) setNewSectionName('') diff --git a/ccm_web/client/src/components/CreateSelectSectionWidget.tsx b/ccm_web/client/src/components/CreateSelectSectionWidget.tsx index c121836f6..25e63e756 100644 --- a/ccm_web/client/src/components/CreateSelectSectionWidget.tsx +++ b/ccm_web/client/src/components/CreateSelectSectionWidget.tsx @@ -6,6 +6,7 @@ import HelpOutline from '@mui/icons-material/HelpOutline' import CreateSectionWidget from './CreateSectionWidget' import SectionSelectorWidget from './SectionSelectorWidget' import { CanvasCourseBase, CanvasCourseSection, CanvasCourseSectionWithCourseName } from '../models/canvas' +import { CsrfToken } from '../models/models' const PREFIX = 'CreateSelectSectionWidget' @@ -68,6 +69,7 @@ interface CreateSelectSectionWidgetBaseProps { sections: CanvasCourseSectionWithCourseName[] selectedSection?: CanvasCourseSectionWithCourseName setSelectedSection: (section: CanvasCourseSectionWithCourseName) => void + csrfToken: CsrfToken } type CreateSelectSectionWidgetProps = CreateSelectSectionWidgetBaseProps & CreateSelectSectionWidgetCreateProps @@ -99,6 +101,7 @@ export default function CreateSelectSectionWidget (props: CreateSelectSectionWid search={[]} multiSelect={false} sections={props.sections} + csrfToken={props.csrfToken} selectedSections={props.selectedSection !== undefined ? [props.selectedSection] : []} selectionUpdated={(sections) => props.setSelectedSection(sections[0])} canUnmerge={false} diff --git a/ccm_web/client/src/components/Layout.tsx b/ccm_web/client/src/components/Layout.tsx index 6a21a3075..cd02b16ed 100644 --- a/ccm_web/client/src/components/Layout.tsx +++ b/ccm_web/client/src/components/Layout.tsx @@ -5,7 +5,7 @@ import BuildIcon from '@mui/icons-material/Build' import Breadcrumbs, { BreadcrumbsProps } from './Breadcrumbs' import ResponsiveHelper from './ResponsiveHelper' -import { getCSRFToken } from '../api' +import { CsrfToken } from '../models/models' const PREFIX = 'Layout' @@ -43,26 +43,29 @@ const StyledGrid = styled(Grid)(( interface LayoutProps extends BreadcrumbsProps { devMode?: boolean children: React.ReactNode + csrfToken?: CsrfToken } export default function Layout (props: LayoutProps): JSX.Element { - const devBlock = props.devMode === true && ( - <> -
- - - Development Mode:  - - - Swagger UI - - -
-
- -
- - ) + const devBlock = props.devMode === true && props.csrfToken ? + ( + <> +
+ + + Development Mode:  + + + Swagger UI + + +
+
+ +
+ + ) : + null return ( diff --git a/ccm_web/client/src/components/MultipleSectionEnrollmentWorkflow.tsx b/ccm_web/client/src/components/MultipleSectionEnrollmentWorkflow.tsx index 118f8c35e..313b285f2 100644 --- a/ccm_web/client/src/components/MultipleSectionEnrollmentWorkflow.tsx +++ b/ccm_web/client/src/components/MultipleSectionEnrollmentWorkflow.tsx @@ -37,7 +37,7 @@ import { REQUIRED_ENROLLMENT_WITH_SECTION_ID_HEADERS, SECTION_ID_TEXT, USER_ID_TEXT, USER_ROLE_TEXT } from '../models/enrollment' import { AddUMUsersLeafProps } from '../models/FeatureUIData' -import { InvalidationType } from '../models/models' +import { CsrfToken, InvalidationType } from '../models/models' import CSVSchemaValidator, { SchemaInvalidation } from '../utils/CSVSchemaValidator' import { EnrollmentInvalidation, LoginIDRowsValidator, RoleRowsValidator, SectionIdRowsValidator @@ -98,7 +98,9 @@ enum CSVWorkflowState { Confirmation } -interface MultipleSectionEnrollmentWorkflowProps extends AddUMUsersLeafProps {} +interface MultipleSectionEnrollmentWorkflowProps extends AddUMUsersLeafProps { + csrfToken: CsrfToken +} export default function MultipleSectionEnrollmentWorkflow (props: MultipleSectionEnrollmentWorkflowProps): JSX.Element { const parser = new FileParserWrapper() @@ -114,7 +116,8 @@ export default function MultipleSectionEnrollmentWorkflow (props: MultipleSectio const [doAddEnrollments, isAddEnrollmentsLoading, addEnrollmentsError, clearAddEnrollmentsError] = usePromise( async (enrollments: AddEnrollmentWithSectionId[]) => { await api.addEnrollmentsToSections( - enrollments.map(e => ({ loginId: e.loginId, role: e.role, sectionId: e.sectionId })) + enrollments.map(e => ({ loginId: e.loginId, role: e.role, sectionId: e.sectionId })), + props.csrfToken.token ) }, () => setWorkflowState(CSVWorkflowState.Confirmation) diff --git a/ccm_web/client/src/components/MultipleUserEnrollmentWorkflow.tsx b/ccm_web/client/src/components/MultipleUserEnrollmentWorkflow.tsx index 855979eae..681b649d0 100644 --- a/ccm_web/client/src/components/MultipleUserEnrollmentWorkflow.tsx +++ b/ccm_web/client/src/components/MultipleUserEnrollmentWorkflow.tsx @@ -25,7 +25,7 @@ import { AddNewExternalUserEnrollment, RowNumberedAddNewExternalUserEnrollment } import { ExternalUserSuccess, isExternalUserSuccess } from '../models/externalUser' import { createSectionRoles } from '../models/feature' import { AddNonUMUsersLeafProps, isAuthorizedForRoles } from '../models/FeatureUIData' -import { CSVWorkflowStep, InvalidationType, RoleEnum } from '../models/models' +import { CSVWorkflowStep, CsrfToken, InvalidationType, RoleEnum } from '../models/models' import CSVSchemaValidator, { SchemaInvalidation } from '../utils/CSVSchemaValidator' import { DuplicateEmailRowsValidator, EmailRowsValidator, EnrollmentInvalidation, FirstNameRowsValidator, @@ -91,6 +91,7 @@ export const isExternalEnrollmentRecord = (record: CSVRecord): record is Externa interface MultipleUserEnrollmentWorkflowProps extends AddNonUMUsersLeafProps { course: CanvasCourseBase + csrfToken: CsrfToken onSectionCreated: (newSection: CanvasCourseSection) => void userCourseRoles: RoleEnum[] } @@ -116,7 +117,8 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol const errors: ErrorDescription[] = [] try { successes = await api.createExternalUsers( - enrollments.map(e => ({ email: e.email, givenName: e.firstName, surname: e.lastName })) + enrollments.map(e => ({ email: e.email, givenName: e.firstName, surname: e.lastName })), + props.csrfToken.token ) } catch (error: unknown) { if (error instanceof ExternalUserProcessError) { @@ -132,7 +134,7 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol if (enrollmentsToAdd.length > 0) { try { await api.addSectionEnrollments( - sectionId, enrollmentsToAdd.map(e => ({ loginId: e.email, role: e.role })) + sectionId, enrollmentsToAdd.map(e => ({ loginId: e.email, role: e.role })), props.csrfToken.token ) } catch (error: unknown) { if (error instanceof CanvasError) { @@ -205,6 +207,7 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol selectedSection={selectedSection} setSelectedSection={setSelectedSection} {...createProps} + csrfToken={props.csrfToken} /> diff --git a/ccm_web/client/src/components/SectionSelectorWidget.tsx b/ccm_web/client/src/components/SectionSelectorWidget.tsx index fe94e761b..605e0bdb0 100644 --- a/ccm_web/client/src/components/SectionSelectorWidget.tsx +++ b/ccm_web/client/src/components/SectionSelectorWidget.tsx @@ -34,6 +34,7 @@ import { unmergeSections } from '../api' import usePromise from '../hooks/usePromise' import { CanvasCourseSectionBase, CanvasCourseSectionWithCourseName, ICanvasCourseSectionSort } from '../models/canvas' import { ISectionSearcher } from '../utils/SectionSearcher' +import { CsrfToken } from '../models/models' const PREFIX = 'SectionSelectorWidget' @@ -204,6 +205,7 @@ interface ISectionSelectorWidgetProps { canUnmerge: boolean sectionsRemoved?: (sections: CanvasCourseSectionBase[]) => void highlightUnlocked?: boolean + csrfToken: CsrfToken } function SectionSelectorWidget (props: ISectionSelectorWidgetProps): JSX.Element { @@ -226,7 +228,7 @@ function SectionSelectorWidget (props: ISectionSelectorWidgetProps): JSX.Element const [searchFieldLabel, setSearchFieldLabel] = useState(props.search.length > 0 ? (props.search)[0].helperText : undefined) const [doUnmerge, isUnmerging, unmergeError] = usePromise( - async (sections: CanvasCourseSectionWithCourseName[]) => await unmergeSections(sections), + async (sections: CanvasCourseSectionWithCourseName[]) => await unmergeSections(sections, props.csrfToken.token), (unmergedSections: CanvasCourseSectionBase[]) => { const unmergedSectionIds = unmergedSections.map(s => s.id) setInternalSections(internalSections.filter(section => !unmergedSectionIds.includes(section.id))) diff --git a/ccm_web/client/src/components/SingleSectionEnrollmentWorkflow.tsx b/ccm_web/client/src/components/SingleSectionEnrollmentWorkflow.tsx index b1d1a1b90..c4ec3b00b 100644 --- a/ccm_web/client/src/components/SingleSectionEnrollmentWorkflow.tsx +++ b/ccm_web/client/src/components/SingleSectionEnrollmentWorkflow.tsx @@ -26,7 +26,7 @@ import { REQUIRED_ENROLLMENT_HEADERS, RowNumberedAddEnrollment, USER_ID_TEXT, USER_ROLE_TEXT } from '../models/enrollment' import { AddUMUsersLeafProps } from '../models/FeatureUIData' -import { CSVWorkflowStep, InvalidationType } from '../models/models' +import { CSVWorkflowStep, CsrfToken, InvalidationType } from '../models/models' import CSVSchemaValidator, { SchemaInvalidation } from '../utils/CSVSchemaValidator' import { EnrollmentInvalidation, LoginIDRowsValidator, RoleRowsValidator } from '../utils/enrollmentValidators' import FileParserWrapper, { CSVRecord } from '../utils/FileParserWrapper' @@ -82,6 +82,7 @@ const Root = styled('div')(( interface SingleSectionEnrollmentWorkflowProps extends AddUMUsersLeafProps { course: CanvasCourseBase + csrfToken: CsrfToken onSectionCreated: (newSection: CanvasCourseSection) => void } @@ -96,7 +97,7 @@ export default function SingleSectionEnrollmentWorkflow (props: SingleSectionEnr const [doAddEnrollments, isAddEnrollmentsLoading, addEnrollmentsError, clearAddEnrollmentsError] = usePromise( async (section: CanvasCourseSectionWithCourseName, enrollments: RowNumberedAddEnrollment[]) => { const apiEnrollments = enrollments.map(e => ({ loginId: e.loginId, role: e.role })) - await api.addSectionEnrollments(section.id, apiEnrollments) + await api.addSectionEnrollments(section.id, apiEnrollments, props.csrfToken.token) }, () => { setActiveStep(CSVWorkflowStep.Confirmation) } ) @@ -174,6 +175,7 @@ export default function SingleSectionEnrollmentWorkflow (props: SingleSectionEnr // Only admins have access to the Add UM Users feature, and they can create sections. canCreate={true} course={props.course} + csrfToken={props.csrfToken} onSectionCreated={(s) => { setSelectedSection(injectCourseName([s], props.course.name)[0]) props.onSectionCreated(s) diff --git a/ccm_web/client/src/components/UserEnrollmentForm.tsx b/ccm_web/client/src/components/UserEnrollmentForm.tsx index a02c14e67..126ace318 100644 --- a/ccm_web/client/src/components/UserEnrollmentForm.tsx +++ b/ccm_web/client/src/components/UserEnrollmentForm.tsx @@ -15,7 +15,7 @@ import usePromise from '../hooks/usePromise' import { CanvasCourseSectionWithCourseName, CanvasUserCondensed, ClientEnrollmentType } from '../models/canvas' import { AddExternalUserEnrollment, AddNewExternalUserEnrollment } from '../models/enrollment' import { AddNonUMUsersLeafProps } from '../models/FeatureUIData' -import { APIErrorWithContext } from '../models/models' +import { APIErrorWithContext, CsrfToken } from '../models/models' import { CanvasError, ExternalUserProcessError } from '../utils/handleErrors' import { emailInputSchema, firstNameInputSchema, lastNameInputSchema, validateString, ValidationResult @@ -66,7 +66,9 @@ interface ExternalEnrollmentSummary { enrolled: boolean } -interface UserEnrollmentFormProps extends AddNonUMUsersLeafProps {} +interface UserEnrollmentFormProps extends AddNonUMUsersLeafProps { + csrfToken: CsrfToken +} export default function UserEnrollmentForm (props: UserEnrollmentFormProps): JSX.Element { const [selectedSection, setSelectedSection] = useState(undefined) @@ -94,7 +96,7 @@ export default function UserEnrollmentForm (props: UserEnrollmentFormProps): JSX const [doAddEnrollment, isAddEnrollmentLoading, addEnrollmentError, clearAddEnrollmentError] = usePromise( async (sectionId: number, enrollment: AddExternalUserEnrollment) => await api.addSectionEnrollments( - sectionId, [{ loginId: enrollment.email, role: enrollment.role }] + sectionId, [{ loginId: enrollment.email, role: enrollment.role }], props.csrfToken.token ), () => setSuccessResult({ createdAndInvited: false, enrolled: true }) ) @@ -105,11 +107,11 @@ export default function UserEnrollmentForm (props: UserEnrollmentFormProps): JSX ] = usePromise( async (sectionId: number, enrollment: AddNewExternalUserEnrollment): Promise => { const { email, firstName, lastName, role } = enrollment - const result = await api.createExternalUsers([{ email, givenName: firstName, surname: lastName }]) + const result = await api.createExternalUsers([{ email, givenName: firstName, surname: lastName }], props.csrfToken.token) let createdAndInvited = false if (result.length > 0 && result[0].userCreated) { createdAndInvited = true - await api.addSectionEnrollments(sectionId, [{ loginId: email, role }]) + await api.addSectionEnrollments(sectionId, [{ loginId: email, role }], props.csrfToken.token) } return { createdAndInvited, enrolled: true } }, @@ -352,6 +354,7 @@ export default function UserEnrollmentForm (props: UserEnrollmentFormProps): JSX search={[]} multiSelect={false} sections={props.sections} + csrfToken={props.csrfToken} selectedSections={selectedSection !== undefined ? [selectedSection] : []} selectionUpdated={(sections) => { if (sections.length === 0) { diff --git a/ccm_web/client/src/hooks/useGlobals.ts b/ccm_web/client/src/hooks/useGlobals.ts index 0f060bc05..dad8755d9 100644 --- a/ccm_web/client/src/hooks/useGlobals.ts +++ b/ccm_web/client/src/hooks/useGlobals.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import usePromise from './usePromise' import * as api from '../api' -import { Globals } from '../models/models' +import { Globals, CsrfToken } from '../models/models' /* Hook for fetching global data, checking whether user is authenticated, and @@ -10,27 +10,32 @@ requesting that the backend set the CSRF token cookie */ function useGlobals (): [ Globals | undefined, + CsrfToken | undefined, boolean | undefined, boolean, Error | undefined, Error | undefined ] { const [globals, setGlobals] = useState(undefined as Globals | undefined) + const [csrfToken, setCsrfToken] = useState(undefined as CsrfToken | undefined) const getGlobals = async (): Promise => await api.getGlobals() const [doGetGlobals, getGlobalsLoading, getGlobalsError] = usePromise( getGlobals, (value: Globals) => setGlobals(value) ) - const setCSRFTokenCookie = async (): Promise => await api.setCSRFTokenCookie() - const [doSetCSRFTokenCookie, setCSRFTokenCookieLoading, setCSRFTokenCookieError] = usePromise(setCSRFTokenCookie) + const getCSRFTokenResponse = async (): Promise => await api.getCSRFTokenResponse() + const [doGetCSRFTokenResponse, getCSRFTokenReponseLoading, getCSRFTokenResponseError] = usePromise( + getCSRFTokenResponse, + (value: CsrfToken) => setCsrfToken(value) + ) useEffect(() => { void doGetGlobals() }, []) useEffect(() => { - void doSetCSRFTokenCookie() + void doGetCSRFTokenResponse() }, []) let isAuthenticated @@ -40,8 +45,8 @@ function useGlobals (): [ isAuthenticated = false } - const loading = getGlobalsLoading || setCSRFTokenCookieLoading - return [globals, isAuthenticated, loading, getGlobalsError, setCSRFTokenCookieError] + const loading = getGlobalsLoading || getCSRFTokenReponseLoading + return [globals, csrfToken, isAuthenticated, loading, getGlobalsError, getCSRFTokenResponseError] } export default useGlobals diff --git a/ccm_web/client/src/models/FeatureUIData.tsx b/ccm_web/client/src/models/FeatureUIData.tsx index a027197d7..c5800aa0c 100644 --- a/ccm_web/client/src/models/FeatureUIData.tsx +++ b/ccm_web/client/src/models/FeatureUIData.tsx @@ -16,11 +16,12 @@ import BulkSectionCreate from '../pages/BulkSectionCreate' import FormatThirdPartyGradebook from '../pages/FormatThirdPartyGradebook' import ConvertCanvasGradebook from '../pages/GradebookCanvas' import MergeSections from '../pages/MergeSections' -import { Globals, RoleEnum } from './models' +import { CsrfToken, Globals, RoleEnum } from './models' import { CanvasCourseBase, CanvasCourseSectionWithCourseName, ClientEnrollmentType } from './canvas' export interface CCMComponentProps { globals: Globals + csrfToken: CsrfToken course: CanvasCourseBase title: string helpURLEnding: string diff --git a/ccm_web/client/src/models/models.ts b/ccm_web/client/src/models/models.ts index a0dcd08e5..e7be4636f 100644 --- a/ccm_web/client/src/models/models.ts +++ b/ccm_web/client/src/models/models.ts @@ -44,6 +44,10 @@ export interface Globals { baseHelpURL: string } +export interface CsrfToken { + token: string +} + // API Errors export interface CanvasAPIErrorPayload { diff --git a/ccm_web/client/src/pages/AddNonUMUsers.tsx b/ccm_web/client/src/pages/AddNonUMUsers.tsx index 60d7d649a..1e05a897b 100644 --- a/ccm_web/client/src/pages/AddNonUMUsers.tsx +++ b/ccm_web/client/src/pages/AddNonUMUsers.tsx @@ -98,6 +98,7 @@ export default function AddNonUMUsers (props: AddNonUMUsersProps): JSX.Element { const renderActivePageState = (state: PageState): JSX.Element => { const commonProps = { sections: sections ?? [], + csrfToken: props.csrfToken, doGetSections: async () => { clearGetSectionsError() setSections(undefined) diff --git a/ccm_web/client/src/pages/AddUMUsers.tsx b/ccm_web/client/src/pages/AddUMUsers.tsx index 9d8e2452c..2a0dac16e 100644 --- a/ccm_web/client/src/pages/AddUMUsers.tsx +++ b/ccm_web/client/src/pages/AddUMUsers.tsx @@ -96,6 +96,7 @@ function AddUMUsers (props: AddUMUsersProps): JSX.Element { const commonProps = { course: props.course, + csrfToken: props.csrfToken, sections: sections ?? [], doGetSections: async () => { clearGetSectionsError() diff --git a/ccm_web/client/src/pages/BulkSectionCreate.tsx b/ccm_web/client/src/pages/BulkSectionCreate.tsx index 4f73c1678..995145da1 100644 --- a/ccm_web/client/src/pages/BulkSectionCreate.tsx +++ b/ccm_web/client/src/pages/BulkSectionCreate.tsx @@ -127,7 +127,7 @@ function BulkSectionCreate (props: CCMComponentProps): JSX.Element { ) const [doAddSections, isAddSectionsLoading, addSectionsError] = usePromise( - async () => await addCourseSections(props.globals.course.id, sectionNames), + async () => await addCourseSections(props.globals.course.id, sectionNames, props.csrfToken.token), (newSections: CanvasCourseSection[]) => { const originalSectionNames: string[] = (existingSectionNames != null) ? existingSectionNames : [] setPageState({ state: BulkSectionCreatePageState.CreateSectionsSuccess, schemaInvalidations: [], rowInvalidations: [] }) diff --git a/ccm_web/client/src/pages/FormatThirdPartyGradebook.tsx b/ccm_web/client/src/pages/FormatThirdPartyGradebook.tsx index 6890335a5..2fc4174e4 100644 --- a/ccm_web/client/src/pages/FormatThirdPartyGradebook.tsx +++ b/ccm_web/client/src/pages/FormatThirdPartyGradebook.tsx @@ -253,6 +253,7 @@ export default function FormatThirdPartyGradebook (props: FormatThirdPartyGradeb search={[]} multiSelect={true} sections={sections !== undefined ? sections : []} + csrfToken={props.csrfToken} selectedSections={selectedSections !== undefined ? selectedSections : []} selectionUpdated={(sections) => { if (sections.length === 0) { diff --git a/ccm_web/client/src/pages/Home.tsx b/ccm_web/client/src/pages/Home.tsx index 7a3dd2a36..90afba45e 100644 --- a/ccm_web/client/src/pages/Home.tsx +++ b/ccm_web/client/src/pages/Home.tsx @@ -14,7 +14,7 @@ import { courseRenameRoles } from '../models/feature' import allFeatures, { FeatureUIGroup, FeatureUIProps, isAuthorizedForAnyFeature, isAuthorizedForFeature, isAuthorizedForRoles } from '../models/FeatureUIData' -import { Globals } from '../models/models' +import { CsrfToken, Globals } from '../models/models' import { CanvasCourseBase } from '../models/canvas' import { courseNameInputSchema, validateString } from '../utils/validation' @@ -43,6 +43,7 @@ const Root = styled('div')(({ theme }) => ({ interface HomeProps { globals: Globals + csrfToken: CsrfToken course: CanvasCourseBase | undefined getCourseError: Error | undefined setCourse: (course: CanvasCourseBase|undefined) => void @@ -54,7 +55,7 @@ function Home (props: HomeProps): JSX.Element { const setCourseNameAsync = async ( newCourseName: string ): Promise => { - return await apiSetCourseName(props.globals.course.id, newCourseName) + return await apiSetCourseName(props.globals.course.id, newCourseName, props.csrfToken.token) } const [doSetCourseName, setCourseNameLoading, setCourseNameError] = usePromise( diff --git a/ccm_web/client/src/pages/MergeSections.tsx b/ccm_web/client/src/pages/MergeSections.tsx index 25a1f4a90..ba1982a71 100644 --- a/ccm_web/client/src/pages/MergeSections.tsx +++ b/ccm_web/client/src/pages/MergeSections.tsx @@ -112,7 +112,7 @@ function MergeSections (props: CCMComponentProps): JSX.Element { } const [doMerge, isMerging, mergeError] = usePromise( - async () => await mergeSections(props.globals.course.id, mergableSections()) + async () => await mergeSections(props.globals.course.id, mergableSections(), props.csrfToken.token), ) useEffect(() => { @@ -198,6 +198,7 @@ function MergeSections (props: CCMComponentProps): JSX.Element { sections={unstagedSections !== undefined ? unstagedSections : []} selectedSections={selectedUnstagedSections} selectionUpdated={setSelectedUnstagedSections} + csrfToken={props.csrfToken} canUnmerge={false}> ) } @@ -223,6 +224,7 @@ function MergeSections (props: CCMComponentProps): JSX.Element { sectionsRemoved={handleUnmergedSections} canUnmerge={isAdmin()} highlightUnlocked={true} + csrfToken={props.csrfToken} > ) diff --git a/ccm_web/package-lock.json b/ccm_web/package-lock.json index be7424056..e50a7abce 100644 --- a/ccm_web/package-lock.json +++ b/ccm_web/package-lock.json @@ -31,13 +31,12 @@ "class-validator": "0.14.1", "connect-session-sequelize": "7.1.7", "cookie-parser": "1.4.6", - "csurf": "1.11.0", + "csrf-csrf": "^3.0.3", "express": "4.18.3", "express-session": "1.18.0", "form-data": "4.0.0", "got": "11.8.5", "helmet": "7.1.0", - "js-cookie": "3.0.5", "keycode-js": "3.1.0", "ltijs": "5.9.3", "ltijs-sequelize": "2.4.4", @@ -63,12 +62,10 @@ "devDependencies": { "@nestjs/testing": "10.3.1", "@types/cookie-parser": "1.4.6", - "@types/csurf": "1.11.5", "@types/express": "4.17.21", "@types/express-session": "1.18.0", "@types/html-webpack-plugin": "3.2.9", "@types/jest": "28.1.4", - "@types/js-cookie": "3.0.6", "@types/morgan": "1.9.9", "@types/node": "20.11.24", "@types/papaparse": "5.3.2", @@ -3024,15 +3021,6 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, - "node_modules/@types/csurf": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", - "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", - "dev": true, - "dependencies": { - "@types/express-serve-static-core": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3226,12 +3214,6 @@ "pretty-format": "^28.0.0" } }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5415,17 +5397,12 @@ "node": ">= 8" } }, - "node_modules/csrf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", - "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "node_modules/csrf-csrf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-3.0.3.tgz", + "integrity": "sha512-NxRERyDiWGH/MLw5KNl46FwVX36vwK0ppLJizNPa7K72FE+3T+WbOotjKkR5V4Q9lPZei+RtcCQna1rMLCjDFQ==", "dependencies": { - "rndm": "1.2.0", - "tsscmp": "1.0.6", - "uid-safe": "2.1.5" - }, - "engines": { - "node": ">= 0.8" + "http-errors": "^2.0.0" } }, "node_modules/css-loader": { @@ -5508,73 +5485,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, - "node_modules/csurf": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", - "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", - "deprecated": "Please use another csrf package", - "dependencies": { - "cookie": "0.4.0", - "cookie-signature": "1.0.6", - "csrf": "3.1.0", - "http-errors": "~1.7.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/csurf/node_modules/cookie": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" - }, - "node_modules/csurf/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/date-utils": { "version": "1.2.21", "resolved": "https://registry.npmjs.org/date-utils/-/date-utils-1.2.21.tgz", @@ -9173,14 +9083,6 @@ "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "engines": { - "node": ">=14" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11839,11 +11741,6 @@ "rimraf": "bin.js" } }, - "node_modules/rndm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", - "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13329,14 +13226,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/ccm_web/package.json b/ccm_web/package.json index 1a1319a68..fb5ea594f 100644 --- a/ccm_web/package.json +++ b/ccm_web/package.json @@ -41,13 +41,12 @@ "class-validator": "0.14.1", "connect-session-sequelize": "7.1.7", "cookie-parser": "1.4.6", - "csurf": "1.11.0", + "csrf-csrf": "^3.0.3", "express": "4.18.3", "express-session": "1.18.0", "form-data": "4.0.0", "got": "11.8.5", "helmet": "7.1.0", - "js-cookie": "3.0.5", "keycode-js": "3.1.0", "ltijs": "5.9.3", "ltijs-sequelize": "2.4.4", @@ -73,12 +72,10 @@ "devDependencies": { "@nestjs/testing": "10.3.1", "@types/cookie-parser": "1.4.6", - "@types/csurf": "1.11.5", "@types/express": "4.17.21", "@types/express-session": "1.18.0", "@types/html-webpack-plugin": "3.2.9", "@types/jest": "28.1.4", - "@types/js-cookie": "3.0.6", "@types/morgan": "1.9.9", "@types/node": "20.11.24", "@types/papaparse": "5.3.2", diff --git a/ccm_web/server/src/api/api.controller.ts b/ccm_web/server/src/api/api.controller.ts index caaef9a7c..81517702d 100644 --- a/ccm_web/server/src/api/api.controller.ts +++ b/ccm_web/server/src/api/api.controller.ts @@ -75,7 +75,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Put('course/:id/name') async putCourseName ( @Param('id', ParseIntPipe) courseId: number, @Body() courseNameDto: CourseNameDto, @UserDec() user: User @@ -86,7 +86,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Post('course/:id/sections') async createSections (@Param('id', ParseIntPipe) courseId: number, @Body() createSectionsDto: CreateSectionsDto, @UserDec() user: User): Promise { const sections = createSectionsDto.sections @@ -104,7 +104,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Post('sections/:id/enroll') async enrollSectionUsers (@Param('id', ParseIntPipe) sectionId: number, @Body() sectionUsersData: SectionUsersDto, @UserDec() user: User): Promise { const users: SectionUserDto[] = sectionUsersData.users @@ -114,7 +114,7 @@ export class APIController { } // Uses admin token, so InvalidTokenInterceptor omitted - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Post('admin/createExternalUsers') async createExternalUsers ( @Body() externalUsersData: ExternalUsersDto @@ -128,7 +128,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Post('/sections/enroll') async enrollUsersToSections ( @Body() enrollmentsDto: SectionEnrollmentsDto, @UserDec() user: User @@ -180,7 +180,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Post('course/:id/sections/merge') async mergeSections ( @Param('id', ParseIntPipe) targetCourseId: number, @@ -194,7 +194,7 @@ export class APIController { } @UseInterceptors(InvalidTokenInterceptor) - @ApiSecurity('CSRF-Token') + @ApiSecurity('x-csrf-token') @Delete('sections/unmerge') async unmergeSections (@Body() sectionIdsData: SectionIdsDto, @UserDec() user: User): Promise { const { sectionIds } = sectionIdsData diff --git a/ccm_web/server/src/auth/auth.controller.ts b/ccm_web/server/src/auth/auth.controller.ts index f2dad2d72..6f1692165 100644 --- a/ccm_web/server/src/auth/auth.controller.ts +++ b/ccm_web/server/src/auth/auth.controller.ts @@ -1,22 +1,23 @@ import { Request, Response } from 'express' -import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common' +import { Controller, ForbiddenException, Get, Req, Res, UseGuards } from '@nestjs/common' import { ApiExcludeEndpoint } from '@nestjs/swagger' - -import { AuthService } from './auth.service' import { JwtAuthGuard } from './jwt-auth.guard' import { SessionGuard } from './session.guard' +import { CSRFTokenResponse } from './auth.interfaces' @UseGuards(JwtAuthGuard, SessionGuard) @Controller('auth') export class AuthController { - constructor (private readonly authService: AuthService) {} - @ApiExcludeEndpoint() @Get('csrfToken') async setCSRFTokenCookie ( @Req() req: Request, @Res({ passthrough: true }) res: Response - ): Promise { - // Cookie options deliberately include defaults of httpOnly false and signed false. - res.cookie('CSRF-Token', req.csrfToken(), this.authService.commonCookieOptions) + ): Promise { + if(req.csrfToken) { + return {token: req.csrfToken()} + } + else { + throw new ForbiddenException() + } } } diff --git a/ccm_web/server/src/auth/auth.interfaces.ts b/ccm_web/server/src/auth/auth.interfaces.ts index 13a8bac2a..4ab2b18b6 100644 --- a/ccm_web/server/src/auth/auth.interfaces.ts +++ b/ccm_web/server/src/auth/auth.interfaces.ts @@ -8,3 +8,7 @@ export interface JwtPayload { export interface MaybeCSRFError extends Error { code?: string } + +export interface CSRFTokenResponse { + token: string +} diff --git a/ccm_web/server/src/auth/auth.module.ts b/ccm_web/server/src/auth/auth.module.ts index 8c041cc6c..d4a52bdd0 100644 --- a/ccm_web/server/src/auth/auth.module.ts +++ b/ccm_web/server/src/auth/auth.module.ts @@ -6,11 +6,12 @@ import { PassportModule } from '@nestjs/passport' import { AuthController } from './auth.controller' import { AuthService } from './auth.service' import { CSRFExceptionFilter } from './csrf.exception.filter' -import { CSRFProtectionMiddleware } from './csrf.middleware' +import { DoubleCSRFProtectionMiddleware } from './double.csrf.middleware' import { JwtStrategy } from './jwt.strategy' import { UserModule } from '../user/user.module' import { Config } from '../config' +import { APP_FILTER } from '@nestjs/core' @Module({ imports: [ @@ -30,7 +31,7 @@ import { Config } from '../config' AuthService, JwtStrategy, { - provide: 'APP_FILTER', + provide: APP_FILTER, useClass: CSRFExceptionFilter } ], @@ -38,6 +39,6 @@ import { Config } from '../config' }) export class AuthModule implements NestModule { configure (consumer: MiddlewareConsumer): void { - consumer.apply(CSRFProtectionMiddleware).forRoutes('/') + consumer.apply(DoubleCSRFProtectionMiddleware).forRoutes('/') } } diff --git a/ccm_web/server/src/auth/csrf.middleware.ts b/ccm_web/server/src/auth/csrf.middleware.ts deleted file mode 100644 index 77e4b0c1f..000000000 --- a/ccm_web/server/src/auth/csrf.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import csurf from 'csurf' -import { NextFunction, Request, RequestHandler, Response } from 'express' -import { Injectable, NestMiddleware } from '@nestjs/common' - -/* -Wrapper for csurf so that it can be installed after LTI middleware (which uses POSTs) -See https://www.npmjs.com/package/csurf -*/ - -@Injectable() -export class CSRFProtectionMiddleware implements NestMiddleware { - csurf: RequestHandler - - constructor () { - this.csurf = csurf() - } - - use (req: Request, res: Response, next: NextFunction): void { - this.csurf(req, res, next) - } -} diff --git a/ccm_web/server/src/auth/double.csrf.middleware.ts b/ccm_web/server/src/auth/double.csrf.middleware.ts new file mode 100644 index 000000000..d3bf6879a --- /dev/null +++ b/ccm_web/server/src/auth/double.csrf.middleware.ts @@ -0,0 +1,23 @@ +import { Injectable, NestMiddleware } from '@nestjs/common' +import { doubleCsrf } from 'csrf-csrf' +import { NextFunction, Request, Response } from 'express' +import { ConfigService } from '@nestjs/config' + +import { Config } from '../config' +import { AuthService } from './auth.service' + +@Injectable() +export class DoubleCSRFProtectionMiddleware implements NestMiddleware { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService) {} + + use(req: Request, res: Response, next: NextFunction): void { + const { doubleCsrfProtection } = doubleCsrf({ + getSecret: () => this.configService.get('server.csrfSecret', { infer: true }), + cookieName: '_Secure-ccm.x-csrf-token', + cookieOptions: this.authService.commonCookieOptions + }) + doubleCsrfProtection(req, res, next) + } +} diff --git a/ccm_web/server/src/config.ts b/ccm_web/server/src/config.ts index ec52e7f2d..eb4f97824 100644 --- a/ccm_web/server/src/config.ts +++ b/ccm_web/server/src/config.ts @@ -10,6 +10,7 @@ export interface ServerConfig { logLevel: LogLevel cookieSecret: string tokenSecret: string + csrfSecret: string maxAgeInSec: number } @@ -145,6 +146,7 @@ export function validateConfig (): Config { logLevel: validate('LOG_LEVEL', env.LOG_LEVEL, isLogLevel, [], 'debug'), tokenSecret: validate('TOKEN_SECRET', env.TOKEN_SECRET, isString, [isNotEmpty], 'TOKENSECRET'), cookieSecret: validate('COOKIE_SECRET', env.COOKIE_SECRET, isString, [isNotEmpty], 'COOKIESECRET'), + csrfSecret: validate('CSRF_SECRET', env.COOKIE_SECRET, isString, [isNotEmpty], 'CSRFSECRET'), maxAgeInSec: validate( 'MAX_AGE_IN_SEC', prepNumber(env.MAX_AGE_IN_SEC), isNumber, [isNotNan, isInteger], (24 * 60 * 60) ) diff --git a/ccm_web/server/src/main.ts b/ccm_web/server/src/main.ts index 232bab7bb..a9648ee23 100644 --- a/ccm_web/server/src/main.ts +++ b/ccm_web/server/src/main.ts @@ -29,15 +29,12 @@ export function doAppCoreSetup (app: INestApplication, serverConfig: PartialServ const payloadSizeLimit = '5mb' app.use(json({ limit: payloadSizeLimit })) app.use(urlencoded({ extended: true, limit: payloadSizeLimit })) - - // Cookies and Sessions - app.use(cookieParser(serverConfig.cookieSecret)) - + const sequelize = app.get(Sequelize) const SequelizeStore = ConnectSessionSequelize(session.Store) const sessionStore = new SequelizeStore({ db: sequelize, tableName: 'session' }) sessionStore.sync({ logging: (sql) => logger.info(sql) }) - + app.use( session({ store: sessionStore, @@ -52,7 +49,9 @@ export function doAppCoreSetup (app: INestApplication, serverConfig: PartialServ maxAge: serverConfig.maxAgeInSec * 1000 } }) - ) + ) + // Cookies and Sessions + app.use(cookieParser(serverConfig.cookieSecret)) // Validation app.useGlobalPipes(new ValidationPipe({ @@ -81,12 +80,12 @@ async function bootstrap (): Promise { .setTitle('Canvas Course Manager') .setDescription('CCM application API description and explorer') .addSecurity( - 'CSRF-Token', { + 'x-csrf-token', { type: 'apiKey', - name: 'CSRF-Token', + name: 'x-csrf-token', in: 'header', description: ( - 'POST and PUT requests need to include a CSRF-Token header. ' + + 'POST and PUT requests need to include a x-csrf-token header. ' + 'The token can be found in the "csrfToken" URL parameter ' + '(use a browser tool to view the URL of the frame).' ) diff --git a/config/.env.sample b/config/.env.sample index c1b066427..a41b201ac 100644 --- a/config/.env.sample +++ b/config/.env.sample @@ -19,6 +19,8 @@ FRAME_DOMAIN=canvas-test.it.umich.edu # TOKEN_SECRET=TOKENSECRET # Secret for securing sessions and cookies (optional) # COOKIE_SECRET=COOKIESECRET +# Secret for securing CSRF tokens (optional) +# CSRF_SECRET=CSRFSECRET # Maximum age in seconds for cookies and tokens (default is 24 hours) (optional) # MAX_AGE_IN_SEC=86400