From 9f7c0977ac3dcf405106f989a89a2d5888e18540 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Wed, 4 Sep 2024 13:30:25 +0800 Subject: [PATCH 1/3] refactor(AssessmentForm): split monitoring components out --- .../components/AssessmentForm/index.tsx | 195 ++---------------- .../BlocksInvalidBrowserFormField.tsx | 42 ++++ .../monitoring/EnableMonitoringFormField.tsx | 55 +++++ .../MonitoringIntervalsFormFields.tsx | 113 ++++++++++ .../MonitoringOptionsFormFields.tsx | 54 +++++ .../components/monitoring/translations.ts | 68 ++++++ 6 files changed, 348 insertions(+), 179 deletions(-) create mode 100644 client/app/bundles/course/assessment/components/monitoring/BlocksInvalidBrowserFormField.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/EnableMonitoringFormField.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/MonitoringIntervalsFormFields.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/translations.ts diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx index 58436fb712b..f5bd51a5423 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx +++ b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx @@ -18,13 +18,11 @@ import { // import AssessmentProgrammingQnList from 'course/admin/pages/CodaveriSettings/components/AssessmentProgrammingQnList'; // import LiveFeedbackToggleButton from 'course/admin/pages/CodaveriSettings/components/buttons/LiveFeedbackToggleButton'; // import { getProgrammingQuestionsForAssessments } from 'course/admin/pages/CodaveriSettings/selectors'; -import BetaChip from 'lib/components/core/BetaChip'; import IconRadio from 'lib/components/core/buttons/IconRadio'; import ErrorText from 'lib/components/core/ErrorText'; // import ExperimentalChip from 'lib/components/core/ExperimentalChip'; import InfoLabel from 'lib/components/core/InfoLabel'; import Section from 'lib/components/core/layouts/Section'; -import Link from 'lib/components/core/Link'; import ConditionsManager from 'lib/components/extensions/conditions/ConditionsManager'; import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerField'; @@ -35,6 +33,9 @@ import { useAppDispatch } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import FileManager from '../FileManager'; +import BlocksInvalidBrowserFormField from '../monitoring/BlocksInvalidBrowserFormField'; +import EnableMonitoringFormField from '../monitoring/EnableMonitoringFormField'; +import MonitoringOptionsFormFields from '../monitoring/MonitoringOptionsFormFields'; import { fetchTabs } from './operations'; import translations from './translations'; @@ -78,7 +79,6 @@ const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { const hasTimeLimit = watch('has_time_limit'); const monitoring = watch('monitoring.enabled'); - const hasMonitoringSecret = watch('monitoring.secret'); // const assessmentId = initialValues.id; // const title = initialValues.title; @@ -744,191 +744,27 @@ const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { {!autograded && passwordProtected && renderPasswordFields()} {passwordProtected && monitoring && ( - ( - - )} + disabled={!canManageMonitor || disabled} /> )} {passwordProtected && monitoringEnabled && ( - ( - ( - - {chunk} - - ), - })} - disabled={!canManageMonitor || disabled} - disabledHint={t(translations.onlyManagersOwnersCanEdit)} - field={field} - fieldState={fieldState} - label={ - - {t(translations.examMonitoring)} - - - } - labelClassName="mt-10" - /> - )} + disabled={!canManageMonitor || disabled} + labelClassName="mt-10" + pulsegridUrl={pulsegridUrl} /> )} {passwordProtected && monitoring && ( - <> - ( - - )} - /> - - - {t(translations.secretHint, { - pulsegrid: (chunk) => ( - - {chunk} - - ), - })} - - - - - - - ( - - {t(translations.milliseconds)} - - ), - }} - label={t(translations.minInterval)} - required - type="number" - variant="filled" - /> - )} - /> - - - - ( - - {t(translations.milliseconds)} - - ), - }} - label={t(translations.maxInterval)} - required - type="number" - variant="filled" - /> - )} - /> - - - - - {t(translations.intervalHint)} - - - - - ( - - {t(translations.milliseconds)} - - ), - }} - label={t(translations.offset)} - required - type="number" - variant="filled" - /> - )} - /> - - - {t(translations.offsetHint)} - - - - + )} @@ -966,7 +802,8 @@ const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { /> )} - {/* + + {/* {editing && (
{ + const { t } = useTranslation(); + + const sessionProtected = useWatch({ name: 'session_protected', control }); + const hasMonitoringSecret = useWatch({ name: 'monitoring.secret', control }); + + return ( + ( + + )} + /> + ); +}; + +export default BlocksInvalidBrowserFormField; diff --git a/client/app/bundles/course/assessment/components/monitoring/EnableMonitoringFormField.tsx b/client/app/bundles/course/assessment/components/monitoring/EnableMonitoringFormField.tsx new file mode 100644 index 00000000000..55267cf714e --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/EnableMonitoringFormField.tsx @@ -0,0 +1,55 @@ +import { Control, Controller } from 'react-hook-form'; + +import BetaChip from 'lib/components/core/BetaChip'; +import Link from 'lib/components/core/Link'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import assessmentFormTranslations from '../AssessmentForm/translations'; + +import translations from './translations'; + +const EnableMonitoringFormField = ({ + control, + pulsegridUrl, + disabled, + labelClassName, +}: { + control: Control; + pulsegridUrl?: string; + disabled?: boolean; + labelClassName?: string; +}): JSX.Element => { + const { t } = useTranslation(); + + return ( + ( + ( + + {chunk} + + ), + })} + disabled={disabled} + disabledHint={t(assessmentFormTranslations.onlyManagersOwnersCanEdit)} + field={field} + fieldState={fieldState} + label={ + + {t(translations.examMonitoring)} + + + } + labelClassName={labelClassName} + /> + )} + /> + ); +}; + +export default EnableMonitoringFormField; diff --git a/client/app/bundles/course/assessment/components/monitoring/MonitoringIntervalsFormFields.tsx b/client/app/bundles/course/assessment/components/monitoring/MonitoringIntervalsFormFields.tsx new file mode 100644 index 00000000000..e4233b35fd0 --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/MonitoringIntervalsFormFields.tsx @@ -0,0 +1,113 @@ +import { Control, Controller } from 'react-hook-form'; +import { Grid, InputAdornment, Typography } from '@mui/material'; + +import FormTextField from 'lib/components/form/fields/TextField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from './translations'; + +const MonitoringIntervalsFormFields = ({ + control, + disabled, +}: { + control: Control; + disabled?: boolean; +}): JSX.Element => { + const { t } = useTranslation(); + + return ( + + + + + ( + + {t(translations.milliseconds)} + + ), + }} + label={t(translations.minInterval)} + required + type="number" + variant="filled" + /> + )} + /> + + + + ( + + {t(translations.milliseconds)} + + ), + }} + label={t(translations.maxInterval)} + required + type="number" + variant="filled" + /> + )} + /> + + + + + {t(translations.intervalHint)} + + + + + ( + + {t(translations.milliseconds)} + + ), + }} + label={t(translations.offset)} + required + type="number" + variant="filled" + /> + )} + /> + + + {t(translations.offsetHint)} + + + + ); +}; + +export default MonitoringIntervalsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx b/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx new file mode 100644 index 00000000000..c0cb70d186d --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx @@ -0,0 +1,54 @@ +import { Control, Controller } from 'react-hook-form'; +import { Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import FormTextField from 'lib/components/form/fields/TextField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MonitoringIntervalsFormFields from './MonitoringIntervalsFormFields'; +import translations from './translations'; + +const MonitoringOptionsFormFields = ({ + control, + pulsegridUrl, + disabled, +}: { + control: Control; + pulsegridUrl?: string; + disabled?: boolean; +}): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + ( + + )} + /> + + + {t(translations.secretHint, { + pulsegrid: (chunk) => ( + + {chunk} + + ), + })} + + + + + ); +}; + +export default MonitoringOptionsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/translations.ts b/client/app/bundles/course/assessment/components/monitoring/translations.ts new file mode 100644 index 00000000000..1b435a33846 --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/translations.ts @@ -0,0 +1,68 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + blocksAccessesFromInvalidSUS: { + id: 'course.assessment.monitoring.blocksAccessesFromInvalidSUS', + defaultMessage: 'Block accesses from browsers with invalid UA', + }, + blocksAccessesFromInvalidSUSHint: { + id: 'course.assessment.monitoring.blocksAccessesFromInvalidSUSHint', + defaultMessage: + 'If enabled, examinees using browsers with invalid UA (does not contain the specified SUS below) will be blocked ' + + 'from accessing this assessment. Instructors can override access with the session unlock password. Heartbeats ' + + 'from an overridden browser session will be flagged as valid in the PulseGrid.', + }, + needSUSAndSessionUnlockPassword: { + id: 'course.assessment.monitoring.needSUSAndSessionUnlockPassword', + defaultMessage: + 'You need to specify a SUS and session unlock password to enable this.', + }, + examMonitoring: { + id: 'course.assessment.monitoring.examMonitoring', + defaultMessage: 'Enable exam monitoring', + }, + examMonitoringHint: { + id: 'course.assessment.monitoring.examMonitoringHint', + defaultMessage: + "If enabled, examinees' sessions will be monitored in real time from the moment they attempt the exam, until they " + + 'finalise it or the first 24 hours since their attempt, whichever is earlier. Instructors can monitor these ' + + 'sessions in PulseGrid.', + }, + minInterval: { + id: 'course.assessment.monitoring.minInterval', + defaultMessage: 'Min interval', + }, + maxInterval: { + id: 'course.assessment.monitoring.maxInterval', + defaultMessage: 'Max interval', + }, + intervalHint: { + id: 'course.assessment.monitoring.intervalHint', + defaultMessage: + "Controls how frequent heartbeats are sent from the examinees' browsers. Intervals are randomised between these " + + 'two ranges.', + }, + offset: { + id: 'course.assessment.monitoring.offset', + defaultMessage: 'Inter-heartbeat offset', + }, + offsetHint: { + id: 'course.assessment.monitoring.offsetHint', + defaultMessage: + 'Controls how long PulseGrid should wait after the frequency interval before flagging a session as late.', + }, + secret: { + id: 'course.assessment.monitoring.secret', + defaultMessage: 'Secret UA Substring (SUS)', + }, + secretHint: { + id: 'course.assessment.monitoring.secretHint', + defaultMessage: + "If provided, the PulseGrid automatically checks if the examinee's browser's User Agent (UA) " + + 'contains this secret, and marks connections that do not as invalid. This string is case-sensitive.', + }, + milliseconds: { + id: 'course.assessment.monitoring.milliseconds', + defaultMessage: 'ms', + }, +}); From b892639286cc2c1ad2e85e4686607d3f00b61b67 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:51:57 +0800 Subject: [PATCH 2/3] feat(AssessmentForm): add browser authorization --- app/models/course/monitoring/monitor.rb | 11 ++ .../course/assessment/monitoring_service.rb | 12 +- .../_monitoring_details.json.jbuilder | 5 +- .../AssessmentForm/useFormValidation.tsx | 22 ++- .../BlocksInvalidBrowserFormField.tsx | 12 +- .../SebConfigKeyOptionsFormFields.tsx | 54 +++++++ .../UserAgentOptionsFormFields.tsx | 50 +++++++ .../common.ts | 16 ++ .../index.tsx | 33 +++++ .../BrowserAuthorizationOptionsFormFields.tsx | 140 ++++++++++++++++++ .../MonitoringOptionsFormFields.tsx | 36 +---- .../components/monitoring/translations.ts | 67 ++++++++- .../course/monitoring/heartbeat.yml | 8 + .../course/monitoring/monitor.yml | 2 + ...add_browser_authorization_to_monitoring.rb | 13 ++ db/schema.rb | 6 +- 16 files changed, 440 insertions(+), 47 deletions(-) create mode 100644 client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/SebConfigKeyOptionsFormFields.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/UserAgentOptionsFormFields.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common.ts create mode 100644 client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/index.tsx create mode 100644 client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationOptionsFormFields.tsx create mode 100644 config/locales/en/activerecord/course/monitoring/heartbeat.yml create mode 100644 db/migrate/20240904091136_add_browser_authorization_to_monitoring.rb diff --git a/app/models/course/monitoring/monitor.rb b/app/models/course/monitoring/monitor.rb index ccdeaa1dc44..8f4a873fc67 100644 --- a/app/models/course/monitoring/monitor.rb +++ b/app/models/course/monitoring/monitor.rb @@ -2,6 +2,8 @@ class Course::Monitoring::Monitor < ApplicationRecord DEFAULT_MIN_INTERVAL_MS = 3000 + enum browser_authorization_method: { user_agent: 0, seb_config_key: 1 } + has_one :assessment, class_name: 'Course::Assessment', inverse_of: :monitor has_many :sessions, class_name: 'Course::Monitoring::Session', inverse_of: :monitor @@ -10,10 +12,13 @@ class Course::Monitoring::Monitor < ApplicationRecord validates :max_interval_ms, numericality: { only_integer: true, greater_than: 0 } validates :offset_ms, numericality: { only_integer: true, greater_than: 0 } validates :blocks, inclusion: { in: [true, false] } + validates :browser_authorization, inclusion: { in: [true, false] } + validates :browser_authorization_method, presence: true validate :max_interval_greater_than_min validate :can_enable_only_when_password_protected validate :can_block_only_when_has_secret_and_session_protected + validate :seb_config_key_required_if_using_seb_config_key_browser_authorization def valid_secret?(string) secret? ? (string&.include?(secret) || false) : true @@ -43,4 +48,10 @@ def can_block_only_when_has_secret_and_session_protected errors.add(:blocks, :must_have_secret_and_session_protection) end + + def seb_config_key_required_if_using_seb_config_key_browser_authorization + return unless browser_authorization_method.to_sym == :seb_config_key && seb_config_key.blank? + + errors.add(:seb_config_key, :required_if_using_seb_config_key_browser_authorization) + end end diff --git a/app/services/course/assessment/monitoring_service.rb b/app/services/course/assessment/monitoring_service.rb index 25e2389ccb9..c866321095f 100644 --- a/app/services/course/assessment/monitoring_service.rb +++ b/app/services/course/assessment/monitoring_service.rb @@ -2,7 +2,17 @@ class Course::Assessment::MonitoringService class << self def params - [:enabled, :secret, :min_interval_ms, :max_interval_ms, :offset_ms, :blocks] + [ + :enabled, + :min_interval_ms, + :max_interval_ms, + :offset_ms, + :blocks, + :browser_authorization, + :browser_authorization_method, + :secret, + :seb_config_key + ] end def unblocked_browser_session_key(assessment_id) diff --git a/app/views/course/assessment/assessments/_monitoring_details.json.jbuilder b/app/views/course/assessment/assessments/_monitoring_details.json.jbuilder index 533c0408c68..43ca3bc5bb2 100644 --- a/app/views/course/assessment/assessments/_monitoring_details.json.jbuilder +++ b/app/views/course/assessment/assessments/_monitoring_details.json.jbuilder @@ -1,9 +1,12 @@ # frozen_string_literal: true json.monitoring do json.enabled @monitor.enabled - json.secret @monitor.secret json.min_interval_ms @monitor.min_interval_ms json.max_interval_ms @monitor.max_interval_ms json.offset_ms @monitor.offset_ms json.blocks @monitor.blocks + json.browser_authorization @monitor.browser_authorization + json.browser_authorization_method @monitor.browser_authorization_method + json.secret @monitor.secret + json.seb_config_key @monitor.seb_config_key end diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx index 77f7cdcf170..6bee90351b2 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx +++ b/client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx @@ -16,6 +16,11 @@ import * as yup from 'yup'; import ft from 'lib/translations/form'; +import { + BROWSER_AUTHORIZATION_METHODS, + BrowserAuthorizationMethod, +} from '../monitoring/BrowserAuthorizationMethodOptionsFormFields/common'; + import t from './translations'; const validationSchema = yup.object({ @@ -103,6 +108,15 @@ const validationSchema = yup.object({ monitoring: yup.object({ enabled: yup.bool(), secret: yup.string().nullable(), + browser_authorization: yup.boolean(), + browser_authorization_method: yup + .string() + .oneOf(BROWSER_AUTHORIZATION_METHODS), + seb_config_key: yup.string().when('browser_authorization_method', { + is: 'seb_config_key' satisfies BrowserAuthorizationMethod, + then: yup.string().required(ft.required), + otherwise: yup.string().nullable(), + }), min_interval_ms: yup.number().when('enabled', { is: true, then: yup @@ -156,11 +170,11 @@ const useFormValidation = ( handleSubmit: (onValid, onInvalid): SubmitHandler => { const postProcessor = (rawData): SubmitHandler => { if (!rawData.session_protected) rawData.session_password = null; - delete rawData.session_protected; if ( - (!rawData.session_password || !rawData.monitoring?.secret) && + (!rawData.session_password || + !rawData.monitoring?.browser_authorization) && rawData.monitoring?.blocks !== undefined ) rawData.monitoring.blocks = false; @@ -173,6 +187,10 @@ const useFormValidation = ( delete rawData.monitoring.max_interval_ms; delete rawData.monitoring.offset_ms; delete rawData.monitoring.blocks; + delete rawData.monitoring.secret; + delete rawData.monitoring.browser_authorization; + delete rawData.monitoring.browser_authorization_method; + delete rawData.monitoring.seb_config_key; } return onValid(rawData); diff --git a/client/app/bundles/course/assessment/components/monitoring/BlocksInvalidBrowserFormField.tsx b/client/app/bundles/course/assessment/components/monitoring/BlocksInvalidBrowserFormField.tsx index 56c586aba69..72446cb1d14 100644 --- a/client/app/bundles/course/assessment/components/monitoring/BlocksInvalidBrowserFormField.tsx +++ b/client/app/bundles/course/assessment/components/monitoring/BlocksInvalidBrowserFormField.tsx @@ -15,7 +15,11 @@ const BlocksInvalidBrowserFormField = ({ const { t } = useTranslation(); const sessionProtected = useWatch({ name: 'session_protected', control }); - const hasMonitoringSecret = useWatch({ name: 'monitoring.secret', control }); + + const enableBrowserAuthorization = useWatch({ + name: 'monitoring.browser_authorization', + control, + }); return ( ( { + const { t } = useTranslation(); + + return ( +
+ ( + + )} + /> + + + {t(translations.sebConfigKeyFieldHint, { + sebConfigKey: (chunk) => ( + + {chunk} + + ), + i: (chunk) => {chunk}, + })} + +
+ ); +}; + +export default SebConfigKeyOptionsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/UserAgentOptionsFormFields.tsx b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/UserAgentOptionsFormFields.tsx new file mode 100644 index 00000000000..32d55f6667f --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/UserAgentOptionsFormFields.tsx @@ -0,0 +1,50 @@ +import { Controller } from 'react-hook-form'; +import { Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import FormTextField from 'lib/components/form/fields/TextField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../translations'; + +import { BrowserAuthorizationMethodOptionsProps } from './common'; + +const UserAgentOptionsFormFields = ({ + control, + pulsegridUrl, + disabled, + className, +}: BrowserAuthorizationMethodOptionsProps): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ ( + + )} + /> + + + {t(translations.secretHint, { + pulsegrid: (chunk) => ( + + {chunk} + + ), + })} + +
+ ); +}; + +export default UserAgentOptionsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common.ts b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common.ts new file mode 100644 index 00000000000..bddc56dd21b --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common.ts @@ -0,0 +1,16 @@ +import { Control } from 'react-hook-form'; + +export const BROWSER_AUTHORIZATION_METHODS = [ + 'user_agent', + 'seb_config_key', +] as const; + +export type BrowserAuthorizationMethod = + (typeof BROWSER_AUTHORIZATION_METHODS)[number]; + +export interface BrowserAuthorizationMethodOptionsProps { + control: Control; + pulsegridUrl?: string; + disabled?: boolean; + className?: string; +} diff --git a/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/index.tsx b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/index.tsx new file mode 100644 index 00000000000..b7e6ad571e7 --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/index.tsx @@ -0,0 +1,33 @@ +import { ElementType } from 'react'; + +import { + BrowserAuthorizationMethod, + BrowserAuthorizationMethodOptionsProps, +} from './common'; +import SebConfigKeyOptionsFormFields from './SebConfigKeyOptionsFormFields'; +import UserAgentOptionsFormFields from './UserAgentOptionsFormFields'; + +const AUTHORIZATION_METHODS: Record< + BrowserAuthorizationMethod, + ElementType +> = { + user_agent: UserAgentOptionsFormFields, + seb_config_key: SebConfigKeyOptionsFormFields, +}; + +const BrowserAuthorizationMethodOptionsFormFields = ({ + authorizationMethod, + ...restProps +}: { + authorizationMethod: BrowserAuthorizationMethod; +} & BrowserAuthorizationMethodOptionsProps): JSX.Element => { + const Component = AUTHORIZATION_METHODS[authorizationMethod]; + if (!Component) + throw new Error( + `Unregistered authorization method: ${authorizationMethod}`, + ); + + return ; +}; + +export default BrowserAuthorizationMethodOptionsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationOptionsFormFields.tsx b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationOptionsFormFields.tsx new file mode 100644 index 00000000000..040687c832a --- /dev/null +++ b/client/app/bundles/course/assessment/components/monitoring/BrowserAuthorizationOptionsFormFields.tsx @@ -0,0 +1,140 @@ +import { Control, Controller, useWatch } from 'react-hook-form'; +import { RadioGroup } from '@mui/material'; + +import RadioButton from 'lib/components/core/buttons/RadioButton'; +import Subsection from 'lib/components/core/layouts/Subsection'; +import Link from 'lib/components/core/Link'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import useTranslation from 'lib/hooks/useTranslation'; + +import assessmentFormTranslations from '../AssessmentForm/translations'; + +import BrowserAuthorizationMethodOptionsFormFields from './BrowserAuthorizationMethodOptionsFormFields'; +import translations from './translations'; + +const BrowserAuthorizationOptionsFormFields = ({ + control, + pulsegridUrl, + disabled, +}: { + control: Control; + pulsegridUrl?: string; + disabled?: boolean; +}): JSX.Element => { + const { t } = useTranslation(); + + const enableBrowserAuthorization = useWatch({ + name: 'monitoring.browser_authorization', + control, + }); + + const authorizationMethod = useWatch({ + name: 'monitoring.browser_authorization_method', + control, + }); + + return ( + <> + ( + ( + + {chunk} + + ), + })} + disabled={disabled} + disabledHint={t( + assessmentFormTranslations.onlyManagersOwnersCanEdit, + )} + field={field} + fieldState={fieldState} + label={t(translations.enableBrowserAuthorization)} + labelClassName="mt-8" + /> + )} + /> + + {enableBrowserAuthorization && ( + ( + + {chunk} + + ), + })} + title={t(translations.browserAuthorizationMethod)} + > + ( + + ( + + {chunk} + + ), + })} + disabled={disabled} + label={t(translations.userAgent)} + value="user_agent" + /> + + ( + + {chunk} + + ), + sebConfigKey: (chunk) => ( + + {chunk} + + ), + })} + disabled={disabled} + label={t(translations.sebConfigKey)} + value="seb_config_key" + /> + + )} + /> + + + + )} + + ); +}; + +export default BrowserAuthorizationOptionsFormFields; diff --git a/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx b/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx index c0cb70d186d..45490f2b7af 100644 --- a/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx +++ b/client/app/bundles/course/assessment/components/monitoring/MonitoringOptionsFormFields.tsx @@ -1,12 +1,7 @@ -import { Control, Controller } from 'react-hook-form'; -import { Typography } from '@mui/material'; - -import Link from 'lib/components/core/Link'; -import FormTextField from 'lib/components/form/fields/TextField'; -import useTranslation from 'lib/hooks/useTranslation'; +import { Control } from 'react-hook-form'; +import BrowserAuthorizationOptionsFormFields from './BrowserAuthorizationOptionsFormFields'; import MonitoringIntervalsFormFields from './MonitoringIntervalsFormFields'; -import translations from './translations'; const MonitoringOptionsFormFields = ({ control, @@ -17,35 +12,14 @@ const MonitoringOptionsFormFields = ({ pulsegridUrl?: string; disabled?: boolean; }): JSX.Element => { - const { t } = useTranslation(); - return ( <> - ( - - )} + disabled={disabled} + pulsegridUrl={pulsegridUrl} /> - - {t(translations.secretHint, { - pulsegrid: (chunk) => ( - - {chunk} - - ), - })} - - ); diff --git a/client/app/bundles/course/assessment/components/monitoring/translations.ts b/client/app/bundles/course/assessment/components/monitoring/translations.ts index 1b435a33846..0fcd0b794b1 100644 --- a/client/app/bundles/course/assessment/components/monitoring/translations.ts +++ b/client/app/bundles/course/assessment/components/monitoring/translations.ts @@ -3,19 +3,19 @@ import { defineMessages } from 'react-intl'; export default defineMessages({ blocksAccessesFromInvalidSUS: { id: 'course.assessment.monitoring.blocksAccessesFromInvalidSUS', - defaultMessage: 'Block accesses from browsers with invalid UA', + defaultMessage: 'Block accesses from unauthorised browsers', }, blocksAccessesFromInvalidSUSHint: { id: 'course.assessment.monitoring.blocksAccessesFromInvalidSUSHint', defaultMessage: - 'If enabled, examinees using browsers with invalid UA (does not contain the specified SUS below) will be blocked ' + - 'from accessing this assessment. Instructors can override access with the session unlock password. Heartbeats ' + - 'from an overridden browser session will be flagged as valid in the PulseGrid.', + "If enabled, examinees using unauthorised browsers can't access this assessment. " + + 'Instructors can override access with the session unlock password. Heartbeats ' + + 'from overridden browser sessions will always be valid (green) in the PulseGrid.', }, needSUSAndSessionUnlockPassword: { id: 'course.assessment.monitoring.needSUSAndSessionUnlockPassword', defaultMessage: - 'You need to specify a SUS and session unlock password to enable this.', + 'You must enable browser authorisation and set a session unlock password to enable this.', }, examMonitoring: { id: 'course.assessment.monitoring.examMonitoring', @@ -24,7 +24,7 @@ export default defineMessages({ examMonitoringHint: { id: 'course.assessment.monitoring.examMonitoringHint', defaultMessage: - "If enabled, examinees' sessions will be monitored in real time from the moment they attempt the exam, until they " + + "If enabled, examinees' sessions will be monitored in real time from when they attempt the exam until they " + 'finalise it or the first 24 hours since their attempt, whichever is earlier. Instructors can monitor these ' + 'sessions in PulseGrid.', }, @@ -58,11 +58,62 @@ export default defineMessages({ secretHint: { id: 'course.assessment.monitoring.secretHint', defaultMessage: - "If provided, the PulseGrid automatically checks if the examinee's browser's User Agent (UA) " + - 'contains this secret, and marks connections that do not as invalid. This string is case-sensitive.', + "If an examinee's browser's User Agent (UA) contains this case-sensitive secret, PulseGrid " + + 'will flag that session as valid, and invalid otherwise. If you leave this blank, all sessions will be flagged as valid.', }, milliseconds: { id: 'course.assessment.monitoring.milliseconds', defaultMessage: 'ms', }, + enableBrowserAuthorization: { + id: 'course.assessment.monitoring.enableBrowserAuthorization', + defaultMessage: 'Authorise browsers that access this assessment', + }, + enableBrowserAuthorizationHint: { + id: 'course.assessment.monitoring.enableBrowserAuthorizationHint', + defaultMessage: + 'If enabled, PulseGrid will additionally check if an examinee is ' + + 'accessing this assessment from an authorised browser, based on the authorisation method you choose.', + }, + userAgent: { + id: 'course.assessment.monitoring.userAgent', + defaultMessage: 'User Agent (UA)', + }, + userAgentHint: { + id: 'course.assessment.monitoring.userAgentHint', + defaultMessage: + "Flags a session as valid if the examinee's browser's User Agent (UA) contains a secret substring.", + }, + sebConfigKeyFieldLabel: { + id: 'course.assessment.monitoring.sebConfigKeyFieldLabel', + defaultMessage: 'SEB Config Key', + }, + sebConfigKeyFieldHint: { + id: 'course.assessment.monitoring.sebConfigKeyFieldHint', + defaultMessage: + 'Your SEB Config Key, not the Browser Exam Key, is generated from your ' + + 'specific SEB configuration. It stays the same across operating systems and SEB versions. Ensure this field ' + + 'is updated if you change your SEB configuration.', + }, + sebConfigKey: { + id: 'course.assessment.monitoring.sebConfigKey', + defaultMessage: 'Safe Exam Browser (SEB) Config Key', + }, + sebConfigKeyHint: { + id: 'course.assessment.monitoring.sebConfigKeyHint', + defaultMessage: + 'Flags a session as valid if the examinee is using Safe Exam Browser (SEB) with a valid configuration. ' + + 'SEB generates a unique Config Key for a specific configuration. This method requires ' + + 'SEB 3.4 for Windows and SEB 3.0 for iOS and macOS, or later.', + }, + browserAuthorizationMethod: { + id: 'course.assessment.monitoring.browserAuthorizationMethod', + defaultMessage: 'Browser authorisation method', + }, + browserAuthorizationMethodHint: { + id: 'course.assessment.monitoring.browserAuthorizationMethodHint', + defaultMessage: + 'Choose how sessions are authorised as valid or invalid. Changes apply to all sessions and heartbeats ' + + 'immediately and updates live in PulseGrid.', + }, }); diff --git a/config/locales/en/activerecord/course/monitoring/heartbeat.yml b/config/locales/en/activerecord/course/monitoring/heartbeat.yml new file mode 100644 index 00000000000..6d82cb59cbe --- /dev/null +++ b/config/locales/en/activerecord/course/monitoring/heartbeat.yml @@ -0,0 +1,8 @@ +en: + activerecord: + errors: + models: + course/monitoring/heartbeat: + attributes: + seb_payload: + invalid_seb_payload: 'seb payload must be either blank or a valid json object' diff --git a/config/locales/en/activerecord/course/monitoring/monitor.yml b/config/locales/en/activerecord/course/monitoring/monitor.yml index 62960d83bc3..c000d74f828 100644 --- a/config/locales/en/activerecord/course/monitoring/monitor.yml +++ b/config/locales/en/activerecord/course/monitoring/monitor.yml @@ -10,3 +10,5 @@ en: greater_than_min_interval: 'must be greater than min interval' blocks: must_have_secret_and_session_protection: 'must have secret and session protection enabled' + seb_config_key: + required_if_using_seb_config_key_browser_authorization: 'must have seb config key if using seb config key browser authorization method' diff --git a/db/migrate/20240904091136_add_browser_authorization_to_monitoring.rb b/db/migrate/20240904091136_add_browser_authorization_to_monitoring.rb new file mode 100644 index 00000000000..34d4b308b6e --- /dev/null +++ b/db/migrate/20240904091136_add_browser_authorization_to_monitoring.rb @@ -0,0 +1,13 @@ +class AddBrowserAuthorizationToMonitoring < ActiveRecord::Migration[7.0] + def change + change_table :course_monitoring_monitors do |t| + t.column :browser_authorization, :boolean, null: false, default: true + t.column :browser_authorization_method, :integer, null: false, default: 0 + t.column :seb_config_key, :string + end + + change_table :course_monitoring_heartbeats do |t| + t.column :seb_payload, :jsonb + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c3320189bf..e4dcc112aa8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_30_090759) do +ActiveRecord::Schema[7.0].define(version: 2024_09_04_091136) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -857,6 +857,7 @@ t.boolean "stale", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.jsonb "seb_payload" t.index ["generated_at"], name: "index_course_monitoring_heartbeats_on_generated_at" t.index ["session_id"], name: "index_course_monitoring_heartbeats_on_session_id" end @@ -870,6 +871,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "blocks", default: false, null: false + t.boolean "browser_authorization", default: true, null: false + t.integer "browser_authorization_method", default: 0, null: false + t.string "seb_config_key" end create_table "course_monitoring_sessions", force: :cascade do |t| From 407cdaa8db5158500854de574b1d953b417918b9 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:48:52 +0800 Subject: [PATCH 3/3] feat(monitoring): implement browser authorisation --- .../course/monitoring/heartbeat_channel.rb | 6 +- .../monitoring/live_monitoring_channel.rb | 9 +- .../monitoring/seb_payload_concern.rb | 22 ++++ .../course/assessment/monitoring_concern.rb | 17 ++- .../submission/monitoring_concern.rb | 2 +- .../course/monitoring_ability_component.rb | 1 + .../monitoring/browser_authorization/base.rb | 10 ++ .../browser_authorization/seb_config_key.rb | 12 ++ .../browser_authorization/user_agent.rb | 6 + app/models/course/monitoring/heartbeat.rb | 24 ++++ app/models/course/monitoring/monitor.rb | 13 +- .../course/assessment/monitoring_service.rb | 6 +- .../submission/monitoring_service.rb | 6 +- client/app/api/Base.ts | 39 +++++- .../app/api/course/Assessment/Assessments.js | 10 ++ .../pages/AssessmentMonitoring/PulseGrid.tsx | 6 +- .../components/ActiveSessionBlob.tsx | 7 +- .../components/HeartbeatDetailCard.tsx | 24 +++- .../components/HeartbeatsTimeline.tsx | 8 +- .../components/SebPayloadDetail.tsx | 59 +++++++++ .../components/SessionBlobLegend.tsx | 4 +- .../components/SessionDetailsPopup.tsx | 10 +- .../components/UserAgentDetail.tsx | 115 ++++++------------ .../components/ValidChip.tsx | 31 +++++ .../course/assessment/reducers/monitoring.ts | 7 +- .../bundles/course/assessment/translations.ts | 15 ++- client/app/declaration.d.ts | 50 ++++++++ client/app/lib/translations/index.ts | 8 -- client/app/types/channels/heartbeat.ts | 3 + client/app/types/channels/liveMonitoring.ts | 10 +- .../app/types/course/assessment/monitoring.ts | 5 + client/app/workers/heartbeatChannel.ts | 3 +- client/app/workers/listeners.ts | 3 +- client/app/workers/types.ts | 3 + client/app/workers/withHeartbeatWorker.tsx | 41 ++++++- .../course/monitoring/monitor.yml | 2 +- .../course/monitoring/monitor.yml | 2 - config/routes.rb | 1 + spec/factories/course_monitoring_monitors.rb | 4 + spec/models/course/monitoring/monitor_spec.rb | 68 ++++++++--- .../assessment/monitoring_service_spec.rb | 26 ++-- .../submission/monitoring_service_spec.rb | 19 +-- 42 files changed, 547 insertions(+), 170 deletions(-) create mode 100644 app/controllers/concerns/course/assessment/monitoring/seb_payload_concern.rb create mode 100644 app/models/course/monitoring/browser_authorization/base.rb create mode 100644 app/models/course/monitoring/browser_authorization/seb_config_key.rb create mode 100644 app/models/course/monitoring/browser_authorization/user_agent.rb create mode 100644 client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SebPayloadDetail.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ValidChip.tsx diff --git a/app/channels/course/monitoring/heartbeat_channel.rb b/app/channels/course/monitoring/heartbeat_channel.rb index ce5a4fe4989..a564178d3d0 100644 --- a/app/channels/course/monitoring/heartbeat_channel.rb +++ b/app/channels/course/monitoring/heartbeat_channel.rb @@ -29,7 +29,8 @@ def pulse(data) session: @session, user_agent: user_agent, ip_address: ip_address, - generated_at: time_from(timestamp) + generated_at: time_from(timestamp), + seb_payload: data['sebPayload'] ) return unless heartbeat.save @@ -128,7 +129,6 @@ def assessment_id end def valid_heartbeat?(heartbeat) - @monitor.valid_secret?(heartbeat.user_agent) || - Course::Assessment::MonitoringService.unblocked?(assessment_id, session) + heartbeat.valid_heartbeat? || Course::Assessment::MonitoringService.unblocked?(assessment_id, session) end end diff --git a/app/channels/course/monitoring/live_monitoring_channel.rb b/app/channels/course/monitoring/live_monitoring_channel.rb index 4665f0e4aaf..32c6a34df67 100644 --- a/app/channels/course/monitoring/live_monitoring_channel.rb +++ b/app/channels/course/monitoring/live_monitoring_channel.rb @@ -49,7 +49,8 @@ def view(data) userAgent: heartbeat.user_agent, ipAddress: heartbeat.ip_address, generatedAt: heartbeat.generated_at, - isValid: @monitor.valid_secret?(heartbeat.user_agent) + isValid: heartbeat.valid_heartbeat?, + sebPayload: heartbeat.seb_payload }.compact end @@ -61,7 +62,6 @@ def view(data) def active_sessions_snapshots @monitor.sessions.includes(:heartbeats, :creator).to_h do |session| last_heartbeat = session.heartbeats.last - is_valid_secret = @monitor.valid_secret?(last_heartbeat&.user_agent) course_user = course_users_hash[session.creator_id] # This technically shouldn't happen, but can happen if someone is removed from @@ -73,7 +73,7 @@ def active_sessions_snapshots status: session.status, misses: session.misses, lastHeartbeatAt: last_heartbeat&.generated_at, - isValid: is_valid_secret, + isValid: last_heartbeat&.valid_heartbeat?, userName: course_user.name, submissionId: submission_ids_hash[session.creator_id], stale: last_heartbeat&.stale @@ -106,7 +106,8 @@ def broadcast_watch(users, snapshots, groups) monitor: { maxIntervalMs: @monitor.max_interval_ms, offsetMs: @monitor.offset_ms, - hasSecret: @monitor.secret? + validates: @monitor.browser_authorization?, + browserAuthorizationMethod: @monitor.browser_authorization_method } } end diff --git a/app/controllers/concerns/course/assessment/monitoring/seb_payload_concern.rb b/app/controllers/concerns/course/assessment/monitoring/seb_payload_concern.rb new file mode 100644 index 00000000000..fca32de568a --- /dev/null +++ b/app/controllers/concerns/course/assessment/monitoring/seb_payload_concern.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +module Course::Assessment::Monitoring::SebPayloadConcern + extend ActiveSupport::Concern + + private + + def seb_payload_from_request(request) + url = request.headers['X-SafeExamBrowser-Url'] + seb_config_key_hash = request.headers['X-SafeExamBrowser-ConfigKeyHash'] + return unless url && seb_config_key_hash + + # It's safe to not strip URL fragments (#) here because fragments are never sent to the server. + { config_key_hash: seb_config_key_hash, url: url } + end + + def stub_heartbeat_from_request(request) + Course::Monitoring::Heartbeat.new( + user_agent: request.user_agent, + seb_payload: seb_payload_from_request(request) + ) + end +end diff --git a/app/controllers/concerns/course/assessment/monitoring_concern.rb b/app/controllers/concerns/course/assessment/monitoring_concern.rb index 7badd0c3a3e..92fafcc5d7b 100644 --- a/app/controllers/concerns/course/assessment/monitoring_concern.rb +++ b/app/controllers/concerns/course/assessment/monitoring_concern.rb @@ -2,6 +2,8 @@ module Course::Assessment::MonitoringConcern extend ActiveSupport::Concern + include Course::Assessment::Monitoring::SebPayloadConcern + included do alias_method :load_monitor, :monitor alias_method :load_can_manage_monitor?, :can_manage_monitor? @@ -11,7 +13,7 @@ module Course::Assessment::MonitoringConcern before_action :load_can_manage_monitor?, only: [:index, :edit] before_action :load_monitoring_component_enabled?, only: [:index, :edit] - before_action :raise_if_no_monitor, only: [:monitoring, :unblock_monitor] + before_action :raise_if_no_monitor, only: [:monitoring, :unblock_monitor, :seb_payload] before_action :check_blocked_by_monitor, only: [:show] end @@ -19,6 +21,15 @@ def monitoring authorize! :read, @monitor end + # We need this endpoint because Safe Exam Browser (SEB) doesn't append keys in request headers + # of WebSocket connections. + def seb_payload + payload = seb_payload_from_request(request) + return head(:ok) unless payload + + render json: payload + end + def unblock_monitor session_password = unblock_monitor_params[:password] @@ -55,7 +66,7 @@ def check_blocked_by_monitor end def blocked_by_monitor? - cannot?(:read, monitor) && monitoring_service&.should_block?(request.user_agent) && !submitted_assessment? + cannot?(:read, monitor) && monitoring_service&.should_block?(request) && !submitted_assessment? end def monitoring_service @@ -77,7 +88,7 @@ def monitor end def should_disable_block? - !@assessment.session_password_protected? || monitor&.secret.blank? + !@assessment.session_password_protected? || !monitor&.browser_authorization? end def submitted_assessment? diff --git a/app/controllers/concerns/course/assessment/submission/monitoring_concern.rb b/app/controllers/concerns/course/assessment/submission/monitoring_concern.rb index 8daf9c0310f..9e63f262b08 100644 --- a/app/controllers/concerns/course/assessment/submission/monitoring_concern.rb +++ b/app/controllers/concerns/course/assessment/submission/monitoring_concern.rb @@ -42,6 +42,6 @@ def check_blocked_by_monitor end def blocked_by_monitor? - should_monitor? && monitoring_service&.should_block?(request.user_agent) + should_monitor? && monitoring_service&.should_block?(request) end end diff --git a/app/models/components/course/monitoring_ability_component.rb b/app/models/components/course/monitoring_ability_component.rb index d4b006127db..d83ae20e1a8 100644 --- a/app/models/components/course/monitoring_ability_component.rb +++ b/app/models/components/course/monitoring_ability_component.rb @@ -42,5 +42,6 @@ def allow_students_create_read_update_sessions_heartbeats can [:create, :read, :update], Course::Monitoring::Session, creator_id: user.id can :create, Course::Monitoring::Heartbeat, session: { creator_id: user.id } + can :seb_payload, Course::Assessment, course_id: course.id end end diff --git a/app/models/course/monitoring/browser_authorization/base.rb b/app/models/course/monitoring/browser_authorization/base.rb new file mode 100644 index 00000000000..4ac30581c59 --- /dev/null +++ b/app/models/course/monitoring/browser_authorization/base.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::Monitoring::BrowserAuthorization::Base + def initialize(monitor) + @monitor = monitor + end + + def valid?(monitor, heartbeat) + raise NotImplementedError + end +end diff --git a/app/models/course/monitoring/browser_authorization/seb_config_key.rb b/app/models/course/monitoring/browser_authorization/seb_config_key.rb new file mode 100644 index 00000000000..31d3df1cd9e --- /dev/null +++ b/app/models/course/monitoring/browser_authorization/seb_config_key.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Course::Monitoring::BrowserAuthorization::SebConfigKey < Course::Monitoring::BrowserAuthorization::Base + # @see https://safeexambrowser.org/developer/seb-config-key.html + def valid_heartbeat?(heartbeat) + seb_payload = heartbeat.seb_payload&.with_indifferent_access + return false unless seb_payload + + url = seb_payload[:url] + hash = Digest::SHA256.hexdigest("#{url}#{@monitor.seb_config_key}") + hash == seb_payload[:config_key_hash] + end +end diff --git a/app/models/course/monitoring/browser_authorization/user_agent.rb b/app/models/course/monitoring/browser_authorization/user_agent.rb new file mode 100644 index 00000000000..14ce18b4b88 --- /dev/null +++ b/app/models/course/monitoring/browser_authorization/user_agent.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class Course::Monitoring::BrowserAuthorization::UserAgent < Course::Monitoring::BrowserAuthorization::Base + def valid_heartbeat?(heartbeat) + @monitor.secret? ? (heartbeat.user_agent&.include?(@monitor.secret) || false) : true + end +end diff --git a/app/models/course/monitoring/heartbeat.rb b/app/models/course/monitoring/heartbeat.rb index 3fe83a5e9fb..e5ac1fa33db 100644 --- a/app/models/course/monitoring/heartbeat.rb +++ b/app/models/course/monitoring/heartbeat.rb @@ -8,13 +8,37 @@ class Course::Monitoring::Heartbeat < ApplicationRecord validates :generated_at, presence: true validates :stale, inclusion: { in: [true, false] } + validate :valid_seb_payload_if_exists + default_scope { order(:generated_at) } before_save :update_session_misses + def valid_heartbeat? + session.monitor.valid_heartbeat?(self) + end + private + SEB_PAYLOAD_SHAPE = { config_key_hash: String, url: String }.freeze + def update_session_misses session.update_misses_after_heartbeat_saved!(self) end + + def filter_seb_payload(seb_payload) + seb_payload.slice(*SEB_PAYLOAD_SHAPE.keys) + end + + def valid_seb_payload?(seb_payload) + seb_payload.with_indifferent_access.tap do |payload| + return SEB_PAYLOAD_SHAPE.all? { |key, type| payload[key].instance_of?(type) } + end + end + + def valid_seb_payload_if_exists + return if seb_payload.present? ? valid_seb_payload?(seb_payload) : true + + errors.add(:seb_payload, :invalid_seb_payload) + end end diff --git a/app/models/course/monitoring/monitor.rb b/app/models/course/monitoring/monitor.rb index 8f4a873fc67..24ed537ce69 100644 --- a/app/models/course/monitoring/monitor.rb +++ b/app/models/course/monitoring/monitor.rb @@ -17,11 +17,12 @@ class Course::Monitoring::Monitor < ApplicationRecord validate :max_interval_greater_than_min validate :can_enable_only_when_password_protected - validate :can_block_only_when_has_secret_and_session_protected + validate :can_block_only_when_has_browser_authorization_and_session_protected validate :seb_config_key_required_if_using_seb_config_key_browser_authorization - def valid_secret?(string) - secret? ? (string&.include?(secret) || false) : true + def valid_heartbeat?(heartbeat) + validator = "Course::Monitoring::BrowserAuthorization::#{browser_authorization_method.to_s.camelize}".constantize + validator.new(self).valid_heartbeat?(heartbeat) end # `Duplicator` already performed a shallow duplicate of the `other` monitor. @@ -43,10 +44,10 @@ def can_enable_only_when_password_protected errors.add(:enabled, :must_be_password_protected) end - def can_block_only_when_has_secret_and_session_protected - return unless blocks? && (secret.blank? || !assessment.session_password_protected?) + def can_block_only_when_has_browser_authorization_and_session_protected + return unless blocks? && (!browser_authorization? || !assessment.session_password_protected?) - errors.add(:blocks, :must_have_secret_and_session_protection) + errors.add(:blocks, :must_have_browser_authorization_and_session_protection) end def seb_config_key_required_if_using_seb_config_key_browser_authorization diff --git a/app/services/course/assessment/monitoring_service.rb b/app/services/course/assessment/monitoring_service.rb index c866321095f..64bf1c9a4e1 100644 --- a/app/services/course/assessment/monitoring_service.rb +++ b/app/services/course/assessment/monitoring_service.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class Course::Assessment::MonitoringService + include Course::Assessment::Monitoring::SebPayloadConcern + class << self def params [ @@ -45,8 +47,8 @@ def upsert!(params) end end - def should_block?(string) - !unblocked? && monitor&.blocks? && !monitor&.valid_secret?(string) + def should_block?(request) + !unblocked? && monitor&.blocks? && !monitor&.valid_heartbeat?(stub_heartbeat_from_request(request)) end def unblock(session_password) diff --git a/app/services/course/assessment/submission/monitoring_service.rb b/app/services/course/assessment/submission/monitoring_service.rb index 540793e9be4..128130248e5 100644 --- a/app/services/course/assessment/submission/monitoring_service.rb +++ b/app/services/course/assessment/submission/monitoring_service.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class Course::Assessment::Submission::MonitoringService + include Course::Assessment::Monitoring::SebPayloadConcern + class << self def for(submission, assessment, browser_session) new(submission, assessment, browser_session) if assessment.monitor_id? @@ -55,8 +57,8 @@ def listening? @monitor.enabled? && session.listening? end - def should_block?(string) - !unblocked? && @monitor&.blocks? && !@monitor&.valid_secret?(string) + def should_block?(request) + !unblocked? && @monitor&.blocks? && !@monitor&.valid_heartbeat?(stub_heartbeat_from_request(request)) end private diff --git a/client/app/api/Base.ts b/client/app/api/Base.ts index cc75d0870a0..7990742583d 100644 --- a/client/app/api/Base.ts +++ b/client/app/api/Base.ts @@ -1,4 +1,8 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import axios, { + AxiosInstance, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; import { getUserToken } from 'utilities/authentication'; import { syncSignals } from 'lib/hooks/unread'; @@ -21,6 +25,37 @@ const updateSignalsIfPresentIn = (response: AxiosResponse): void => { syncSignals(JSON.parse(signals)); }; +const getAbsoluteURLWithoutHashFromAxiosRequestConfig = ( + config: InternalAxiosRequestConfig, +): string => { + const url = new URL(window.location.href); + url.pathname = config.url!; + url.hash = ''; + + Object.entries(config.params).forEach(([key, value]) => + url.searchParams.set(key, value as string), + ); + + return url.toString(); +}; + +/** + * We need this because Safe Exam Browser (SEB) only appends the config key hash in the + * request headers as `X-SafeExamBrowser-ConfigKeyHash` without the original URL used + * to hash it. + * + * The server shouldn't simply take the received request's URL because it's possible + * that the server sits behind a reverse proxy and only receives the request via a + * proxied internal URL. The safest way to ensure the server can correctly verify the + * config key hash is to also include the request URL at request time. + */ +const appendRequestURLIfOnSEB = (config: InternalAxiosRequestConfig): void => { + if (!navigator.userAgent.includes('SEB/')) return; + + config.headers['X-SafeExamBrowser-Url'] = + getAbsoluteURLWithoutHashFromAxiosRequestConfig(config); +}; + const getAuthorizationToken = (): string => { const userToken = getUserToken(); return `Bearer ${userToken}`; @@ -57,9 +92,11 @@ export default class BaseAPI { client.interceptors.request.use(async (config) => { config.withCredentials = true; + appendRequestURLIfOnSEB(config); if (config.method === 'get') return config; config.headers['X-CSRF-Token'] = await this.#getAndSaveCSRFToken(); + return config; }); diff --git a/client/app/api/course/Assessment/Assessments.js b/client/app/api/course/Assessment/Assessments.js index 7a0de93bb51..2d41e462a0f 100644 --- a/client/app/api/course/Assessment/Assessments.js +++ b/client/app/api/course/Assessment/Assessments.js @@ -41,6 +41,16 @@ export default class AssessmentsAPI extends BaseCourseAPI { ); } + /** + * + * @returns {import('api/types').APIResponse} + */ + fetchSebPayload() { + return this.client.get( + `${this.#urlPrefix}/${this.assessmentId}/seb_payload`, + ); + } + /** * Create an assessment. * diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/PulseGrid.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/PulseGrid.tsx index 14b0d18ce16..8caf17bca51 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/PulseGrid.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/PulseGrid.tsx @@ -28,7 +28,7 @@ const PulseGrid = (props: PulseGridProps): JSX.Element => { const { t } = useTranslation(); const [userIds, setUserIds] = useState([]); const [groups, setGroups] = useState([]); - const [hasSecret, setHasSecret] = useState(false); + const [validates, setValidates] = useState(false); const monitoring = useMonitoring(); const [rejected, setRejected] = useState(false); @@ -37,7 +37,7 @@ const PulseGrid = (props: PulseGridProps): JSX.Element => { watch: (data) => { setUserIds(data.userIds); setGroups(data.groups); - setHasSecret(data.monitor.hasSecret); + setValidates(data.monitor.validates); monitoring.initialize(data.monitor, data.snapshots); monitoring.notifyConnected(); }, @@ -69,7 +69,7 @@ const PulseGrid = (props: PulseGridProps): JSX.Element => { - + diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ActiveSessionBlob.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ActiveSessionBlob.tsx index ecd690400c8..888a94bb645 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ActiveSessionBlob.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ActiveSessionBlob.tsx @@ -36,7 +36,9 @@ const BaseActiveSessionBlob = ( ): JSX.Element => { const { of: snapshot, for: userId } = props; - const { hasSecret } = useAppSelector(select('monitor')); + const { validates, browserAuthorizationMethod } = useAppSelector( + select('monitor'), + ); const monitoring = useMonitoring(); @@ -66,9 +68,9 @@ const BaseActiveSessionBlob = ( { props.getHeartbeats?.(snapshot.sessionId, -1); setPopupData((data) => [data?.[0], new Date().toISOString()]); @@ -80,6 +82,7 @@ const BaseActiveSessionBlob = ( open={Boolean(snapshot.recentHeartbeats && popupData?.[0])} showing={snapshot.recentHeartbeats ?? []} submissionId={snapshot.submissionId} + validates={validates} /> ); diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatDetailCard.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatDetailCard.tsx index 84d7c1b8abf..0bf54d4db20 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatDetailCard.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatDetailCard.tsx @@ -1,16 +1,19 @@ import { Chip, Tooltip, Typography } from '@mui/material'; import { HeartbeatDetail } from 'types/channels/liveMonitoring'; +import { BrowserAuthorizationMethod } from 'course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common'; import useTranslation from 'lib/hooks/useTranslation'; import { formatPreciseDateTime } from 'lib/moment'; import translations from '../../../translations'; +import SebPayloadDetail from './SebPayloadDetail'; import UserAgentDetail from './UserAgentDetail'; interface HeartbeatDetailCardProps { of: HeartbeatDetail; - hasSecret?: boolean; + validates?: boolean; + browserAuthorizationMethod?: BrowserAuthorizationMethod; className?: string; delta?: number; } @@ -76,7 +79,24 @@ const HeartbeatDetailCard = (props: HeartbeatDetailCardProps): JSX.Element => { +
+ +
+ + {t(translations.sebPayload)} + + +
diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatsTimeline.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatsTimeline.tsx index 1e331ff6456..ea2939f22d5 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatsTimeline.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/HeartbeatsTimeline.tsx @@ -2,12 +2,15 @@ import { useState } from 'react'; import moment from 'moment'; import { HeartbeatDetail } from 'types/channels/liveMonitoring'; +import { BrowserAuthorizationMethod } from 'course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common'; + import HeartbeatDetailCard from './HeartbeatDetailCard'; import HeartbeatsTimelineChart from './HeartbeatsTimelineChart'; interface HeartbeatsTimelineProps { in: HeartbeatDetail[]; - hasSecret?: boolean; + validates?: boolean; + browserAuthorizationMethod?: BrowserAuthorizationMethod; } /** @@ -48,10 +51,11 @@ const HeartbeatsTimeline = (props: HeartbeatsTimelineProps): JSX.Element => { {heartbeats[hoveredIndex] && ( )} diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SebPayloadDetail.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SebPayloadDetail.tsx new file mode 100644 index 00000000000..a0e4f49a623 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SebPayloadDetail.tsx @@ -0,0 +1,59 @@ +import { Launch, Tag } from '@mui/icons-material'; +import { Typography } from '@mui/material'; +import { SebPayload } from 'types/course/assessment/monitoring'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../translations'; + +import ValidChip from './ValidChip'; + +const SebPayloadDetail = ({ + of: payload, + valid, + validates, +}: { + of: SebPayload | undefined; + valid?: boolean; + validates?: boolean; +}): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {validates && } + + {payload ? ( +
+
+ + + + {payload.config_key_hash} + +
+ +
+ + + + {payload.url} + +
+
+ ) : ( + + {t(translations.blankField)} + + )} +
+ ); +}; + +export default SebPayloadDetail; diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionBlobLegend.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionBlobLegend.tsx index de1313b93b2..7711b099b06 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionBlobLegend.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionBlobLegend.tsx @@ -10,7 +10,7 @@ import { PRESENCE_COLORS } from './ActiveSessionBlob'; import SessionBlob from './SessionBlob'; interface SessionBlobLegendProps { - hasSecret: boolean; + validates: boolean; } const SessionBlobLegend = (props: SessionBlobLegendProps): JSX.Element => { @@ -50,7 +50,7 @@ const SessionBlobLegend = (props: SessionBlobLegendProps): JSX.Element => { - {props.hasSecret + {props.validates ? t(translations.alivePresenceHintSUSMatches) : t(translations.alivePresenceHint)} diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionDetailsPopup.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionDetailsPopup.tsx index 822a2b562b2..0bc8b8e8ce8 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionDetailsPopup.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/SessionDetailsPopup.tsx @@ -4,6 +4,7 @@ import { Close } from '@mui/icons-material'; import { IconButton, Popover, Typography } from '@mui/material'; import { HeartbeatDetail } from 'types/channels/liveMonitoring'; +import { BrowserAuthorizationMethod } from 'course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common'; import Link from 'lib/components/core/Link'; import useTranslation from 'lib/hooks/useTranslation'; import { formatPreciseDateTime } from 'lib/moment'; @@ -19,7 +20,8 @@ interface SessionDetailsPopupProps { onClose: () => void; generatedAt?: string; anchorsOn?: HTMLElement; - hasSecret?: boolean; + validates?: boolean; + browserAuthorizationMethod?: BrowserAuthorizationMethod; onClickShowAllHeartbeats?: () => void; submissionId?: number; } @@ -90,7 +92,11 @@ const SessionDetailsPopup = (props: SessionDetailsPopupProps): JSX.Element => { - + ); diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/UserAgentDetail.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/UserAgentDetail.tsx index 8e821d1c976..290ca4dc89c 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/UserAgentDetail.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/UserAgentDetail.tsx @@ -1,92 +1,55 @@ -import { Apple, Cancel, CheckCircle, WindowSharp } from '@mui/icons-material'; -import { Chip, Typography } from '@mui/material'; +import { ComponentProps } from 'react'; +import { Apple, Public, WindowSharp } from '@mui/icons-material'; +import { SvgIcon, Typography } from '@mui/material'; -import useTranslation, { - Descriptor, - MessageTranslator, -} from 'lib/hooks/useTranslation'; -import commonTranslations from 'lib/translations'; +import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../../translations'; -interface UserAgentDetailProps { - of?: string; - className?: string; - validate?: boolean; - valid?: boolean; -} - -const platforms = { - windows: { - icon: , - label: commonTranslations.windows, - }, - macos: { - icon: , - label: commonTranslations.macos, - }, -} satisfies Record; - -const getPlatformChipFromUserAgent = ( - userAgent: string, - t: MessageTranslator, -): JSX.Element | null => { - let platform: keyof typeof platforms | undefined; +import ValidChip from './ValidChip'; - if (userAgent.includes('Windows')) { - platform = 'windows'; - } else if (userAgent.includes('Mac')) { - platform = 'macos'; - } +const PlatformIcon = ({ + userAgent, + ...iconProps +}: { userAgent: string } & ComponentProps): JSX.Element => { + if (userAgent.includes('Mac')) return ; - if (!platform) return null; - - const { icon, label } = platforms[platform]; + if (userAgent.includes('Windows')) + return ( + + ); - return ; + return ; }; -const UserAgentDetail = (props: UserAgentDetailProps): JSX.Element => { - const { of: userAgent, className } = props; - +const UserAgentDetail = ({ + of: userAgent, + validates, + valid, +}: { + of?: string; + validates?: boolean; + valid?: boolean; +}): JSX.Element => { const { t } = useTranslation(); - if (userAgent === undefined) - return ( - - {t(translations.blankField)} - - ); - - const platformChip = getPlatformChipFromUserAgent(userAgent, t); - return (
-
- {props.validate && ( - : } - label={ - props.valid - ? t(translations.validHeartbeat) - : t(translations.invalidHeartbeat) - } - size="small" - variant="outlined" - /> - )} - - {platformChip} -
- - - {userAgent} - + {validates && } + + {userAgent ? ( +
+ + {userAgent} +
+ ) : ( + + {t(translations.blankField)} + + )}
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ValidChip.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ValidChip.tsx new file mode 100644 index 00000000000..643b4afed0f --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/components/ValidChip.tsx @@ -0,0 +1,31 @@ +import { ComponentProps } from 'react'; +import { Cancel, CheckCircle } from '@mui/icons-material'; +import { Chip } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../../translations'; + +const ValidChip = ({ + valid, + ...chipProps +}: { valid?: boolean } & ComponentProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + : } + label={ + valid + ? t(translations.validHeartbeat) + : t(translations.invalidHeartbeat) + } + size="small" + variant="outlined" + /> + ); +}; + +export default ValidChip; diff --git a/client/app/bundles/course/assessment/reducers/monitoring.ts b/client/app/bundles/course/assessment/reducers/monitoring.ts index 0db27869684..e75f733ba67 100644 --- a/client/app/bundles/course/assessment/reducers/monitoring.ts +++ b/client/app/bundles/course/assessment/reducers/monitoring.ts @@ -12,7 +12,12 @@ const initialState: MonitoringState = { snapshots: {}, history: [], status: 'connecting', - monitor: { maxIntervalMs: 0, offsetMs: 0, hasSecret: false }, + monitor: { + maxIntervalMs: 0, + offsetMs: 0, + validates: false, + browserAuthorizationMethod: 'user_agent', + }, }; export const monitoringSlice = createSlice({ diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 90a24475c84..30d6648d8a7 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -987,6 +987,10 @@ const translations = defineMessages({ id: 'course.assessment.monitoring.userAgent', defaultMessage: 'User Agent', }, + sebPayload: { + id: 'course.assessment.monitoring.sebPayload', + defaultMessage: 'Safe Exam Browser (SEB) Config Key Hash & URL', + }, type: { id: 'course.assessment.monitoring.type', defaultMessage: 'Type', @@ -1078,7 +1082,8 @@ const translations = defineMessages({ }, alivePresenceHintSUSMatches: { id: 'course.assessment.monitoring.alivePresenceHintSUSMatches', - defaultMessage: 'Last heartbeat was received in time and the SUS matches.', + defaultMessage: + 'Last heartbeat was received in time and came from an authorised browser, if browser authorisation is enabled.', }, latePresenceHint: { id: 'course.assessment.monitoring.latePresenceHint', @@ -1087,15 +1092,17 @@ const translations = defineMessages({ }, missingPresenceHint: { id: 'course.assessment.monitoring.missingPresenceHint', - defaultMessage: "Next heartbeat hasn't been received in time.", + defaultMessage: + "Next heartbeat hasn't been received in time, or the last heartbeat came from an unauthorised browser, " + + 'if browser authorisation is enabled.', }, validHeartbeat: { id: 'course.assessment.monitoring.validHeartbeat', - defaultMessage: 'Valid UA', + defaultMessage: 'Valid', }, invalidHeartbeat: { id: 'course.assessment.monitoring.invalidHeartbeat', - defaultMessage: 'Invalid UA', + defaultMessage: 'Invalid', }, invalidBrowser: { id: 'course.assessment.monitoring.invalidBrowser', diff --git a/client/app/declaration.d.ts b/client/app/declaration.d.ts index e2f417088b7..70732a1a08f 100644 --- a/client/app/declaration.d.ts +++ b/client/app/declaration.d.ts @@ -35,4 +35,54 @@ declare module '*.md' { interface Window { _CSRF_TOKEN?: string; + + /** + * Safe Exam Browser (SEB) JavaScript API. This should be available in SEB 3.4 for Windows + * and SEB 3.0 for iOS and macOS, or later. + * + * For macOS configurations, set the `browserWindowWebView` to the policy "Prefer Modern" + * (value `3`) to enable the SEB JavaScript API. + * + * @see https://safeexambrowser.org/developer/seb-config-key.html + * + * Types are obtained from the SEB source code. + * + * @see https://github.com/SafeExamBrowser/seb-mac/blob/6208692490f312566db251532b76b62ed33b9176/Classes/BrowserComponents/SEBAbstractModernWebView.swift + */ + SafeExamBrowser?: { + /** + * Has the format `appDisplayName__versionString_buildNumber_bundleID`. `` currently + * can have the values `iOS`, `macOS` or `Windows`. + * + * This is set regardless whether `updateKeys()` is called. + */ + version: string; + security: { + appVersion: string; + + /** + * The Browser Exam Key (BEK) hashed with the URL of the page. + * + * @see https://safeexambrowser.org/developer/seb-config-key.html + */ + browserExamKey: string; + + /** + * The Config Key (CK) hashed with the URL of the page. + * + * @see https://safeexambrowser.org/developer/seb-config-key.html + */ + configKey: string; + + /** + * In SEB 3.0 for macOS/iOS, this function needs to be invoked first (for example in + * ``). Indicate a `callback` function as parameter, which will be called + * asynchronously by SEB after updating `browserExamKey` and `configKey`. + * + * In SEB 3.3.2 for Windows and SEB 3.1 for macOS and iOS, calling this function is not + * necessary, the `browserExamKey` and `configKey` are already set when the page is loaded. + */ + updateKeys: (callback: () => void) => void; + }; + }; } diff --git a/client/app/lib/translations/index.ts b/client/app/lib/translations/index.ts index 92a9d316fea..202d223b21f 100644 --- a/client/app/lib/translations/index.ts +++ b/client/app/lib/translations/index.ts @@ -21,14 +21,6 @@ const translations = defineMessages({ id: 'lib.translations.experimental', defaultMessage: 'Experimental', }, - windows: { - id: 'lib.translations.windows', - defaultMessage: 'Windows', - }, - macos: { - id: 'lib.translations.macos', - defaultMessage: 'macOS', - }, }); export default translations; diff --git a/client/app/types/channels/heartbeat.ts b/client/app/types/channels/heartbeat.ts index aa1923ee50d..c42a339cb32 100644 --- a/client/app/types/channels/heartbeat.ts +++ b/client/app/types/channels/heartbeat.ts @@ -1,5 +1,8 @@ +import { SebPayload } from 'types/course/assessment/monitoring'; + export interface HeartbeatPostData { timestamp: number; + sebPayload?: SebPayload; } export interface NextActionData { diff --git a/client/app/types/channels/liveMonitoring.ts b/client/app/types/channels/liveMonitoring.ts index 6eaf2d62f9a..af47878b880 100644 --- a/client/app/types/channels/liveMonitoring.ts +++ b/client/app/types/channels/liveMonitoring.ts @@ -1,15 +1,21 @@ +import { SebPayload } from 'types/course/assessment/monitoring'; + +import { BrowserAuthorizationMethod } from 'course/assessment/components/monitoring/BrowserAuthorizationMethodOptionsFormFields/common'; + export interface MonitoringMonitorData { maxIntervalMs: number; offsetMs: number; - hasSecret: boolean; + validates: boolean; + browserAuthorizationMethod: BrowserAuthorizationMethod; } export interface HeartbeatDetail { - isValid: boolean; stale: boolean; userAgent: string; ipAddress: string; generatedAt: string; + isValid: boolean; + sebPayload?: SebPayload; } export interface SnapshotData { diff --git a/client/app/types/course/assessment/monitoring.ts b/client/app/types/course/assessment/monitoring.ts index de9346a0a73..12999402244 100644 --- a/client/app/types/course/assessment/monitoring.ts +++ b/client/app/types/course/assessment/monitoring.ts @@ -3,3 +3,8 @@ export interface MonitoringRequestData { monitorId: number; title: string; } + +export interface SebPayload { + config_key_hash: string; + url: string; +} diff --git a/client/app/workers/heartbeatChannel.ts b/client/app/workers/heartbeatChannel.ts index 85b5fd99a54..982f7137ece 100644 --- a/client/app/workers/heartbeatChannel.ts +++ b/client/app/workers/heartbeatChannel.ts @@ -9,6 +9,7 @@ interface HeartbeatChannelCallbacks { resetInterval: (action: () => void, interval: number) => void; onPulse?: (heartbeat: HeartbeatPostData) => void; onPulsed?: (timestamp: number) => void; + getHeartbeatData: () => HeartbeatPostData; getFlushData?: () => Promise; onFlushed?: (from: number, to: number) => void; onTerminate?: () => void; @@ -27,7 +28,7 @@ const flushThenPulseOn = async ( const flushData = await callbacks.getFlushData?.(); if (flushData?.length) channel.perform('flush', { heartbeats: flushData }); - const heartbeat: HeartbeatPostData = { timestamp: Date.now() }; + const heartbeat = callbacks.getHeartbeatData(); callbacks.onPulse?.(heartbeat); channel.perform('pulse', heartbeat); diff --git a/client/app/workers/listeners.ts b/client/app/workers/listeners.ts index 74ed1435cd0..95f75a8c59f 100644 --- a/client/app/workers/listeners.ts +++ b/client/app/workers/listeners.ts @@ -24,7 +24,7 @@ const terminateWorker = async (): Promise => { const createListeners = ( host: HeartbeatWorkerListenerHost, ): HeartbeatWorkerListener => ({ - start: async ({ url, sessionId, courseId }): Promise => { + start: async ({ url, sessionId, courseId, sebPayload }): Promise => { storage ??= await setUpDatabase(); channel ??= subscribe(url, sessionId, courseId, { @@ -34,6 +34,7 @@ const createListeners = ( storage?.updateLastSuccessfulPulse(timestamp); storage?.removeHeartbeat(timestamp); }, + getHeartbeatData: () => ({ timestamp: Date.now(), sebPayload }), getFlushData: storage?.getHeartbeats, onFlushed: storage?.removeHeartbeats, onTerminate: terminateWorker, diff --git a/client/app/workers/types.ts b/client/app/workers/types.ts index 518c659aca9..35a4fb34a65 100644 --- a/client/app/workers/types.ts +++ b/client/app/workers/types.ts @@ -1,7 +1,10 @@ +import type { SebPayload } from 'types/course/assessment/monitoring'; + interface StartPayload { url: string; sessionId: number; courseId: number; + sebPayload?: SebPayload; } export interface HeartbeatWorkerListener { diff --git a/client/app/workers/withHeartbeatWorker.tsx b/client/app/workers/withHeartbeatWorker.tsx index 87d6abde639..df92060b298 100644 --- a/client/app/workers/withHeartbeatWorker.tsx +++ b/client/app/workers/withHeartbeatWorker.tsx @@ -1,8 +1,10 @@ import { ComponentType, useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import type { SebPayload } from 'types/course/assessment/monitoring'; import { getIdFromUnknown } from 'utilities'; import { getWebSocketURL } from 'utilities/socket'; +import CourseAPI from 'api/course'; import usePrompt from 'lib/hooks/router/usePrompt'; import { getWorkerType, setUpWorker } from './constructors'; @@ -12,6 +14,31 @@ interface WrappedComponentProps { setSessionId?: (sessionId: number) => void; } +const stripHashFromURL = (string: string): string => { + const url = new URL(string); + url.hash = ''; + return url.toString(); +}; + +const fetchSebPayloadFromServer = async (): Promise => { + const response = await CourseAPI.assessment.assessments.fetchSebPayload(); + const payload = response.data; + if (!payload) return undefined; + + payload.url = stripHashFromURL(payload.url); + return payload; +}; + +const getSebPayload = async (): Promise => { + const configKeyHash = window.SafeExamBrowser?.security.configKey; + if (!configKeyHash) return fetchSebPayloadFromServer(); + + return { + config_key_hash: configKeyHash, + url: stripHashFromURL(window.location.href), + }; +}; + const withHeartbeatWorker =

( Component: ComponentType

, ): ComponentType

=> { @@ -34,10 +61,16 @@ const withHeartbeatWorker =

( const worker = setUpWorker(workerType); workerRef.current = worker; - worker.postMessage({ - type: 'start', - payload: { url: getWebSocketURL(), sessionId, courseId }, - }); + (async (): Promise => + worker.postMessage({ + type: 'start', + payload: { + url: getWebSocketURL(), + sessionId, + courseId, + sebPayload: await getSebPayload(), + }, + }))(); const terminateWorker = (): void => { worker.terminate(); diff --git a/config/locales/en/activerecord/course/monitoring/monitor.yml b/config/locales/en/activerecord/course/monitoring/monitor.yml index c000d74f828..cfac223ccd7 100644 --- a/config/locales/en/activerecord/course/monitoring/monitor.yml +++ b/config/locales/en/activerecord/course/monitoring/monitor.yml @@ -9,6 +9,6 @@ en: max_interval_ms: greater_than_min_interval: 'must be greater than min interval' blocks: - must_have_secret_and_session_protection: 'must have secret and session protection enabled' + must_have_browser_authorization_and_session_protection: 'must have browser authorization and session protection enabled' seb_config_key: required_if_using_seb_config_key_browser_authorization: 'must have seb config key if using seb config key browser authorization method' diff --git a/config/locales/zh/activerecord/course/monitoring/monitor.yml b/config/locales/zh/activerecord/course/monitoring/monitor.yml index cc263f5f175..1a54f1c4af4 100644 --- a/config/locales/zh/activerecord/course/monitoring/monitor.yml +++ b/config/locales/zh/activerecord/course/monitoring/monitor.yml @@ -8,5 +8,3 @@ zh: must_be_password_protected: '评估必须受密码保护才能启用' max_interval_ms: greater_than_min_interval: '必须大于最小间隔' - blocks: - must_have_secret_and_session_protection: '必须启用秘密和会话保护' diff --git a/config/routes.rb b/config/routes.rb index 80ec033656a..b0dc58d3087 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -222,6 +222,7 @@ get :requirements, on: :member get :statistics, on: :member get :monitoring, on: :member + get :seb_payload, on: :member resources :questions, only: [] do post 'duplicate/:destination_assessment_id', on: :member, action: 'duplicate', as: :duplicate diff --git a/spec/factories/course_monitoring_monitors.rb b/spec/factories/course_monitoring_monitors.rb index 2a53ba772f2..9ed5e68d78d 100644 --- a/spec/factories/course_monitoring_monitors.rb +++ b/spec/factories/course_monitoring_monitors.rb @@ -20,5 +20,9 @@ with_secret blocks { true } end + + trait :with_seb_config_key do + browser_authorization_method { :seb_config_key } + end end end diff --git a/spec/models/course/monitoring/monitor_spec.rb b/spec/models/course/monitoring/monitor_spec.rb index 79b55b369b0..b2eb7b70ae9 100644 --- a/spec/models/course/monitoring/monitor_spec.rb +++ b/spec/models/course/monitoring/monitor_spec.rb @@ -78,32 +78,66 @@ end end - describe '#valid_secret?' do - context 'when secret is not set' do - subject { create(:course_monitoring_monitor, secret: nil) } - - it 'always returns true' do - expect(subject.valid_secret?('anything')).to be_truthy + describe '#valid_heartbeat?' do + context 'when browser_authorization_method is user_agent' do + context 'when secret is not set' do + subject { create(:course_monitoring_monitor, secret: nil) } + + it 'always returns true' do + heartbeat = create(:course_monitoring_heartbeat) + expect(subject.valid_heartbeat?(heartbeat)).to be_truthy + end end - end - context 'when secret is set' do - subject { create(:course_monitoring_monitor, secret: 'something') } + context 'when secret is set' do + subject { create(:course_monitoring_monitor, secret: 'something') } + + it 'returns true if the given substring matches' do + heartbeat = create(:course_monitoring_heartbeat, user_agent: subject.secret) + expect(subject.valid_heartbeat?(heartbeat)).to be(true) + end - it 'returns true if the given substring matches' do - expect(subject.valid_secret?('something')).to be(true) + it 'returns true if the given substring includes' do + heartbeat = create(:course_monitoring_heartbeat, user_agent: "#{subject.secret} weird") + expect(subject.valid_heartbeat?(heartbeat)).to be(true) + end + + it 'returns false if the given substring does not include' do + heartbeat = create(:course_monitoring_heartbeat) + expect(subject.valid_heartbeat?(heartbeat)).to be(false) + end end + end - it 'returns true if the given substring includes' do - expect(subject.valid_secret?('something weird')).to be(true) + context 'when browser_authorization_method is seb_config_key' do + subject do + create( + :course_monitoring_monitor, + :with_seb_config_key, + seb_config_key: '5521fd207deab9de034f67869d429ae97585b85cf977a0bed298c03cb9027995' + ) end - it 'returns false if the given substring does not include' do - expect(subject.valid_secret?('anything')).to be(false) + context 'when the given payload is valid' do + let(:heartbeat) do + create(:course_monitoring_heartbeat, seb_payload: { + config_key_hash: '5d0b0ab4ae35649b60ad45b2e6c3520b5b7a3367b03207ebcd986c79fc002f6f', + url: 'http://192.168.1.25:8080/AuthenticatedApp.js' + }) + end + + it { expect(subject.valid_heartbeat?(heartbeat)).to be(true) } end - it 'returns false if the given substring is nil' do - expect(subject.valid_secret?(nil)).to be(false) + context 'when the given payload is invalid' do + let(:heartbeat) do + create(:course_monitoring_heartbeat, seb_payload: { + config_key_hash: SecureRandom.hex, + url: SecureRandom.hex + }) + end + + it { expect(subject.valid_heartbeat?(heartbeat)).to be(false) } end end end diff --git a/spec/services/course/assessment/monitoring_service_spec.rb b/spec/services/course/assessment/monitoring_service_spec.rb index 92b31990b64..eb49027878c 100644 --- a/spec/services/course/assessment/monitoring_service_spec.rb +++ b/spec/services/course/assessment/monitoring_service_spec.rb @@ -32,7 +32,8 @@ end it 'never blocks' do - expect(subject.should_block?(SecureRandom.hex)).to be_falsey + request = ActionDispatch::Request.new({ 'HTTP_USER_AGENT' => base_user_agent }) + expect(subject.should_block?(request)).to be_falsey end end @@ -63,8 +64,13 @@ end context 'when the monitor blocks' do - let(:valid_user_agent) { "#{base_user_agent} #{monitor.secret}" } - let(:invalid_user_agent) { "#{base_user_agent} #{SecureRandom.hex}" } + let(:valid_request) do + ActionDispatch::Request.new({ 'HTTP_USER_AGENT' => "#{base_user_agent} #{monitor.secret}" }) + end + + let(:invalid_request) do + ActionDispatch::Request.new({ 'HTTP_USER_AGENT' => "#{base_user_agent} #{SecureRandom.hex}" }) + end before do assessment.update!(session_password: SecureRandom.hex) @@ -72,24 +78,22 @@ end it 'blocks when the user agent is invalid' do - expect(subject.should_block?(invalid_user_agent)).to be_truthy + expect(subject.should_block?(invalid_request)).to be_truthy end it 'does not block when the user agent is valid' do - expect(subject.should_block?(valid_user_agent)).to be_falsey + expect(subject.should_block?(valid_request)).to be_falsey end it 'does not block when the user agent is invalid but the browser session is unblocked' do browser_session[described_class.unblocked_browser_session_key(assessment.id)] = true - expect(subject.should_block?(invalid_user_agent)).to be_falsey + expect(subject.should_block?(invalid_request)).to be_falsey end it 'can unblock a browser with an invalid user agent' do - invalid_user_agent = "#{base_user_agent} #{SecureRandom.hex}" - expect { subject.unblock(assessment.session_password) }. - to change { subject.should_block?(invalid_user_agent) }.from(true).to(false). + to change { subject.should_block?(invalid_request) }.from(true).to(false). and change { browser_session }.from({}) end end @@ -102,8 +106,8 @@ expect { subject.upsert!(blocks: true) }.to raise_error(ActiveRecord::RecordInvalid) end - it 'cannot be set to block if the monitor does not have a secret' do - monitor.update!(secret: nil) + it 'cannot be set to block if browser authorisation is disabled' do + monitor.update!(browser_authorization: false) assessment.update!(session_password: SecureRandom.hex) expect { subject.upsert!(blocks: true) }.to raise_error(ActiveRecord::RecordInvalid) diff --git a/spec/services/course/assessment/submission/monitoring_service_spec.rb b/spec/services/course/assessment/submission/monitoring_service_spec.rb index 4bd19c3f91c..c491bc1f03d 100644 --- a/spec/services/course/assessment/submission/monitoring_service_spec.rb +++ b/spec/services/course/assessment/submission/monitoring_service_spec.rb @@ -61,8 +61,13 @@ end describe '#should_block?' do - let(:valid_user_agent) { "#{base_user_agent} #{monitor.secret}" } - let(:invalid_user_agent) { "#{base_user_agent} #{SecureRandom.hex}" } + let(:valid_request) do + ActionDispatch::Request.new({ 'HTTP_USER_AGENT' => "#{base_user_agent} #{monitor.secret}" }) + end + + let(:invalid_request) do + ActionDispatch::Request.new({ 'HTTP_USER_AGENT' => "#{base_user_agent} #{SecureRandom.hex}" }) + end before { monitor.update!(secret: SecureRandom.hex) } @@ -73,17 +78,17 @@ end it 'blocks when the user agent is invalid' do - expect(subject.should_block?(invalid_user_agent)).to be_truthy + expect(subject.should_block?(invalid_request)).to be_truthy end it 'does not block when the user agent is valid' do - expect(subject.should_block?(valid_user_agent)).to be_falsey + expect(subject.should_block?(valid_request)).to be_falsey end it 'does not block when the user agent is invalid but the browser session is unblocked' do Course::Assessment::MonitoringService.new(assessment, browser_session).unblock(assessment.session_password) - expect(subject.should_block?(invalid_user_agent)).to be_falsey + expect(subject.should_block?(invalid_request)).to be_falsey end end @@ -91,11 +96,11 @@ before { monitor.update!(blocks: false) } it 'does not block when the user agent is invalid' do - expect(subject.should_block?(invalid_user_agent)).to be_falsey + expect(subject.should_block?(invalid_request)).to be_falsey end it 'does not block when the user agent is valid' do - expect(subject.should_block?(valid_user_agent)).to be_falsey + expect(subject.should_block?(valid_request)).to be_falsey end end end