Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
### 🚨 Breaking changes

### ✨ New features and improvements
- Added JupyterHub session tokens in `jupyter/create_session` route (#8135)
- Added experimental support for JupyterHub integration for assignment submission (#7986)
- Enforced restriction on grouping deletion when submissions exist (#8134)
- Improved table selection column styling, fixed table overflow in containers, and hid unused scrollbars (#8133)
Expand Down
135 changes: 111 additions & 24 deletions app/controllers/jupyter/jupyter_submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,57 @@
}.freeze

RESPONSE_BODY_TRUNCATE_LENGTH = 500
JUPYTER_SESSION_TTL = 15.minutes

# The Jupyter endpoint resolves the submitting user separately, so it
# should not require an existing MarkUs browser session.
skip_before_action :verify_authenticity_token, only: [:submit], raise: false
skip_before_action :authenticate, only: [:submit]
skip_before_action :check_record, only: [:submit]
skip_before_action :check_course_switch, only: [:submit]
# codeql[rb/csrf-protection-disabled] -- authenticated via header token, not cookies, so CSRF does not apply
skip_before_action :verify_authenticity_token, only: [:create_session, :submit], raise: false
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
david-yz-liu marked this conversation as resolved.
Dismissed

skip_verify_authorized only: :submit
# The Jupyter endpoints resolve the submitting user separately, so they
# do not require an existing MarkUs browser session.
skip_before_action :authenticate, only: [:create_session, :submit]
skip_before_action :check_record, only: [:create_session, :submit]
skip_before_action :check_course_switch, only: [:create_session, :submit]

skip_verify_authorized only: [:create_session, :submit]

before_action :ensure_jupyter_enabled!
before_action :authenticate_jupyter_session!, only: [:submit]

# Verifies the caller's JupyterHub token and mints a short-lived signed session token.
def create_session
jupyter_info = create_session_params[:jupyter]
origin, = parse_jupyter_base_url!(jupyter_info[:base_url])
token = jupyter_info[:token]

user_name = find_username_from_jupyter_token!(origin, token)
session_token, expires_at = encode_jupyter_session(user_name: user_name, origin: origin, token: token)

render json: {
status: 'success',
session_token: session_token,
expires_at: expires_at.iso8601,
markus_user_name: user_name
}
rescue StandardError => e
render_error(e)
end

def submit
payload = submit_params

jupyter_info = payload[:jupyter]
jupyter_path = payload[:notebook_path].to_s
destination_path = File.basename(jupyter_path)
if destination_path.blank?
raise ArgumentError, I18n.t('jupyter.submit.missing_destination_filename')
end

origin, base_path = parse_jupyter_base_url!(jupyter_info[:base_url])
token = jupyter_info[:token]

user = find_user_from_jupyter_token!(origin, token)
course = find_course_from_payload!(payload)

student = course.students.find_by(user_id: user.id)
student = course.students.find_by(user_id: current_user.id)

if student.nil?
raise ForbiddenError,
I18n.t('jupyter.submit.not_a_student', user_name: user.user_name, course_name: course.name)
I18n.t('jupyter.submit.not_a_student', user_name: current_user.user_name, course_name: course.name)
end

assignment = find_assignment_from_payload!(payload, student)
Expand All @@ -61,7 +79,7 @@
raise ForbiddenError, I18n.t('submissions.api_submission_disabled')
end

jupyter_file = fetch_jupyter_file!(origin, base_path, token, jupyter_path)
jupyter_file = fetch_jupyter_file!(@jupyter_origin, @jupyter_base_path, @jupyter_token, jupyter_path)

submit_jupyter_file!(
assignment: assignment,
Expand All @@ -79,10 +97,16 @@
course: course.name,
assignment_id: assignment.id,
assignment: assignment.short_identifier,
markus_user_name: user.user_name
markus_user_name: current_user.user_name
}
}
rescue StandardError => e
render_error(e)
end

private

def render_error(e)
status = ERROR_STATUSES.find { |error_class, _| e.is_a?(error_class) }&.last
if status.nil?
Rails.logger.error("Jupyter submission failed: #{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
Expand All @@ -100,8 +124,6 @@
end
end

private

def ensure_jupyter_enabled!
return if Settings.jupyter.enabled

Expand All @@ -111,11 +133,38 @@
}, status: :service_unavailable
end

def submit_params
params.require([:notebook_path, :jupyter])
# Resolves +current_user+ (via +@real_user+, mirroring Api::MainApiController#authenticate)
# from a previously-issued session token, without touching the MarkUs session cookie.
# Stashes the parsed Jupyter origin/base_path/token as ivars so +submit+ doesn't need to
# re-parse +jupyter.base_url+.
def authenticate_jupyter_session!
params.require(:jupyter).require([:base_url, :token])
jupyter_info = params.require(:jupyter).permit(:base_url, :token)
session_token = params[:session_token]

if session_token.blank?
raise IdentityError, I18n.t('jupyter.submit.missing_session_token')
end

params.permit(:notebook_path, :course_id, :course, :assignment_id, :assignment,
origin, base_path = parse_jupyter_base_url!(jupyter_info[:base_url])
@jupyter_origin = origin
@jupyter_base_path = base_path
@jupyter_token = jupyter_info[:token]
@real_user = decode_jupyter_session!(session_token, origin: origin, token: @jupyter_token)
rescue StandardError => e
render_error(e)
end

def create_session_params
params.require(:jupyter).require([:base_url, :token])

params.permit(jupyter: [:base_url, :token])
end

def submit_params
params.require(:notebook_path)

params.permit(:notebook_path, :session_token, :course_id, :course, :assignment_id, :assignment,
jupyter: [:base_url, :token])
end

Expand Down Expand Up @@ -155,7 +204,7 @@
raise BadRequestError, I18n.t('jupyter.submit.unparseable_base_url', error: e.message)
end

def find_user_from_jupyter_token!(origin, token)
def find_username_from_jupyter_token!(origin, token)
uri = URI.parse("#{origin}/hub/api/user")
model = jupyter_api_get!(uri, token, error_class: IdentityError)
name = model['name']
Expand All @@ -164,13 +213,51 @@
raise IdentityError, I18n.t('jupyter.submit.missing_username')
end

user = User.find_by(user_name: name)
unless User.exists?(user_name: name)
raise ActiveRecord::RecordNotFound, I18n.t('jupyter.submit.unknown_user', user_name: name.inspect)
end

name
end

def encode_jupyter_session(user_name:, origin:, token:)
expires_at = JUPYTER_SESSION_TTL.from_now
payload = {
'user_name' => user_name,
'origin' => origin,
'token_hash' => Digest::SHA256.hexdigest(token),
'expires_at' => expires_at.to_i
}

[Rails.application.message_verifier(:jupyter_session).generate(payload), expires_at]
end

# Verifies a session token minted by +encode_jupyter_session+. Verifies request
# params origin and JupyterHub token against the session token.
def decode_jupyter_session!(session_token, origin:, token:)
payload = Rails.application.message_verifier(:jupyter_session).verify(session_token)

if payload['expires_at'].to_i < Time.current.to_i
raise IdentityError, I18n.t('jupyter.submit.session_expired')
end

unless payload['origin'] == origin
raise IdentityError, I18n.t('jupyter.submit.session_origin_mismatch')
end

unless ActiveSupport::SecurityUtils.secure_compare(payload['token_hash'], Digest::SHA256.hexdigest(token))
raise IdentityError, I18n.t('jupyter.submit.session_token_mismatch')
end

user = User.find_by(user_name: payload['user_name'])

if user.nil?
raise ActiveRecord::RecordNotFound, I18n.t('jupyter.submit.unknown_user', user_name: name.inspect)
raise ActiveRecord::RecordNotFound, I18n.t('jupyter.submit.unknown_user', user_name: payload['user_name'])
end

user
rescue ActiveSupport::MessageVerifier::InvalidSignature
raise IdentityError, I18n.t('jupyter.submit.invalid_session_token')
end

def find_course_from_payload!(payload)
Expand Down
5 changes: 4 additions & 1 deletion config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
headers: :any,
methods: [:post]

# New JupyterLab extension submission endpoint.
# New JupyterLab submission extension endpoints.
resource %r{/jupyter/authenticate},
headers: :any,
methods: [:post, :options]
resource %r{/jupyter/submit},
headers: :any,
methods: [:post, :options]
Expand Down
5 changes: 5 additions & 0 deletions config/locales/views/jupyter/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ en:
invalid_base_url_path: Jupyter base_url "%{base_url}" must not contain ".." path segments.
invalid_json_response: 'JupyterHub response from %{uri} was not valid JSON: %{error}'
invalid_jupyter_url: 'Invalid Jupyter URL: %{error}'
invalid_session_token: Jupyter session_token is invalid.
missing_assignment: Submission data must contain "assignment_id" or "assignment".
missing_course: Submission data must contain "course_id" or "course" field.
missing_destination_filename: Could not determine a destination filename for the submission.
missing_session_token: Submission data must contain a "session_token" obtained from jupyter/authenticate.
missing_username: JupyterHub identity response did not include a username.
not_a_student: MarkUs user "%{user_name}" is not a student in course "%{course_name}".
origin_not_allowed: Jupyter origin "%{origin}" is not in the configured list of allowed hosts.
request_failed: 'JupyterHub request to %{uri} returned HTTP %{code}: %{body}'
session_expired: Jupyter session_token has expired. Please authenticate again.
session_origin_mismatch: Jupyter session_token was issued for a different Jupyter origin.
session_token_mismatch: Jupyter session_token does not match the supplied Jupyter token.
unknown_user: No MarkUs user exists with user_name=%{user_name}.
unparseable_base_url: 'Invalid Jupyter base_url: %{error}'
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@
post 'main/logout', controller: 'main', action: 'logout'

namespace :jupyter do
post 'authenticate', controller: 'jupyter_submissions', action: 'create_session'
post 'submit', controller: 'jupyter_submissions', action: 'submit'
end

Expand Down
Loading