canvas-lms/lib/authentication_methods.rb

420 lines
16 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2011-02-01 09:57:29 +08:00
#
# Copyright (C) 2011 - present Instructure, Inc.
2011-02-01 09:57:29 +08:00
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module AuthenticationMethods
class AccessTokenError < Exception
end
class AccessTokenScopeError < StandardError
end
class LoggedOutError < Exception
end
def self.access_token(request, params_method = :params)
auth_header = request.authorization
if auth_header.present? && (header_parts = auth_header.split(' ', 2)) && header_parts[0] == 'Bearer'
header_parts[1]
else
request.send(params_method)['access_token'].presence
end
end
def self.user_id(request)
request.session[:user_id]
end
def load_pseudonym_from_inst_access_token(token_string)
token = ::AuthenticationMethods::InstAccessToken.parse(token_string)
return false unless token
auth_context = ::AuthenticationMethods::InstAccessToken.load_user_and_pseudonym_context(token, @domain_root_account)
@current_user = auth_context[:current_user]
@current_pseudonym = auth_context[:current_pseudonym]
raise AccessTokenError unless @current_user && @current_pseudonym
if auth_context[:real_current_user]
@real_current_user = auth_context[:real_current_user]
@real_current_pseudonym = auth_context[:real_current_pseudonym]
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
end
@authenticated_with_jwt = @authenticated_with_inst_access_token = true
end
def load_pseudonym_from_jwt
return unless api_request?
token_string = AuthenticationMethods.access_token(request)
return unless token_string.present?
return if load_pseudonym_from_inst_access_token(token_string)
begin
Add asymmetric encryption for service tokens refs FOO-2410 test plan: - in dynamic_settings.yml, add the following block: ``` store: canvas: services-jwt: # these are all the same JWK but with different kid # to generate a new key, run the following in a Canvas console: # # key = OpenSSL::PKey::RSA.generate(2048) # key.public_key.to_jwk(kid: Time.now.utc.iso8601).to_json jwk-past.json: "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ\",\"kid\":\"2018-05-18T22:33:20Z_a\",\"d\":\"pYwR64x-LYFtA13iHIIeEvfPTws50ZutyGfpHN-kIZz3k-xVpun2Hgu0hVKZMxcZJ9DkG8UZPqD-zTDbCmCyLQ\",\"p\":\"6OQ2bi_oY5fE9KfQOcxkmNhxDnIKObKb6TVYqOOz2JM\",\"q\":\"y-UBef95njOrqMAxJH1QPds3ltYWr8QgGgccmcATH1M\",\"dp\":\"Ol_xkL7rZgNFt_lURRiJYpJmDDPjgkDVuafIeFTS4Ic\",\"dq\":\"RtzDY5wXr5TzrwWEztLCpYzfyAuF_PZj1cfs976apsM\",\"qi\":\"XA5wnwIrwe5MwXpaBijZsGhKJoypZProt47aVCtWtPE\"}" jwk-present.json: "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ\",\"kid\":\"2018-06-18T22:33:20Z_b\",\"d\":\"pYwR64x-LYFtA13iHIIeEvfPTws50ZutyGfpHN-kIZz3k-xVpun2Hgu0hVKZMxcZJ9DkG8UZPqD-zTDbCmCyLQ\",\"p\":\"6OQ2bi_oY5fE9KfQOcxkmNhxDnIKObKb6TVYqOOz2JM\",\"q\":\"y-UBef95njOrqMAxJH1QPds3ltYWr8QgGgccmcATH1M\",\"dp\":\"Ol_xkL7rZgNFt_lURRiJYpJmDDPjgkDVuafIeFTS4Ic\",\"dq\":\"RtzDY5wXr5TzrwWEztLCpYzfyAuF_PZj1cfs976apsM\",\"qi\":\"XA5wnwIrwe5MwXpaBijZsGhKJoypZProt47aVCtWtPE\"}" jwk-future.json: "{\"kty\":\"RSA\",\"e\":\"AQAB\",\"n\":\"uX1MpfEMQCBUMcj0sBYI-iFaG5Nodp3C6OlN8uY60fa5zSBd83-iIL3n_qzZ8VCluuTLfB7rrV_tiX727XIEqQ\",\"kid\":\"2018-07-18T22:33:20Z_c\",\"d\":\"pYwR64x-LYFtA13iHIIeEvfPTws50ZutyGfpHN-kIZz3k-xVpun2Hgu0hVKZMxcZJ9DkG8UZPqD-zTDbCmCyLQ\",\"p\":\"6OQ2bi_oY5fE9KfQOcxkmNhxDnIKObKb6TVYqOOz2JM\",\"q\":\"y-UBef95njOrqMAxJH1QPds3ltYWr8QgGgccmcATH1M\",\"dp\":\"Ol_xkL7rZgNFt_lURRiJYpJmDDPjgkDVuafIeFTS4Ic\",\"dq\":\"RtzDY5wXr5TzrwWEztLCpYzfyAuF_PZj1cfs976apsM\",\"qi\":\"XA5wnwIrwe5MwXpaBijZsGhKJoypZProt47aVCtWtPE\"}" ``` - Ensure /internal/services/jwks loads correctly - In console, ensure `CanvasSecurity::ServicesJwt.decrypt(Base64.decode64(CanvasSecurity::ServicesJwt.for_user('localhost', User.first)))` and `CanvasSecurity::ServicesJwt.decrypt(Base64.decode64(CanvasSecurity::ServicesJwt.for_user('localhost', User.first, symmetric: true)))` both work and produce sensible looking output Change-Id: I13c6c35cc92ed12d03bf97e89e590614e11c6d47 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/275160 QA-Review: August Thornton <august@instructure.com> Product-Review: August Thornton <august@instructure.com> Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Ethan Vizitei <evizitei@instructure.com> Reviewed-by: Evan Battaglia <ebattaglia@instructure.com>
2021-10-05 03:20:31 +08:00
services_jwt = CanvasSecurity::ServicesJwt.new(token_string)
@current_user = User.find(services_jwt.user_global_id)
@current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)
unless @current_user && @current_pseudonym
raise AccessTokenError
end
if services_jwt.masquerading_user_global_id
@real_current_user = User.find(services_jwt.masquerading_user_global_id)
@real_current_pseudonym = SisPseudonym.for(@real_current_user, @domain_root_account, type: :implicit, require_sis: false)
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
end
@authenticated_with_jwt = true
rescue JSON::JWT::InvalidFormat, # definitely not a JWT
Canvas::Security::TokenExpired, # it could be a JWT, but it's expired if so
Canvas::Security::InvalidToken # not formatted like a JWT
# these will happen for some configurations (no consul)
# and for some normal use cases (old token, access token),
# so we can return and move on
return
end
end
ALLOWED_SCOPE_INCLUDES = %w{uuid}
def filter_includes(key)
# no funny business
params.delete(key) unless params[key].class == Array
return unless params.key?(key)
params[key] &= ALLOWED_SCOPE_INCLUDES
end
def validate_scopes
return unless @access_token
developer_key = @access_token.developer_key
request_method = request.method.casecmp('HEAD') == 0 ? 'GET' : request.method.upcase
if developer_key.try(:require_scopes)
scope_patterns = @access_token.url_scopes_for_method(request_method).concat(AccessToken.always_allowed_scopes)
if scope_patterns.any? { |scope| scope =~ request.path }
unless developer_key.try(:allow_includes)
filter_includes(:include)
filter_includes(:includes)
end
else
raise AccessTokenScopeError
end
end
end
def self.graphql_type_authorized?(access_token, type)
if access_token&.developer_key&.require_scopes
# allowing the root query type for now, but any other type is forbidden
type == "Query"
else
true
end
end
def load_pseudonym_from_access_token
return unless api_request? ||
(params[:controller] == 'oauth2_provider' && params[:action] == 'destroy') ||
(params[:controller] == 'login' && params[:action] == 'session_token')
token_string = AuthenticationMethods.access_token(request)
if token_string
@access_token = AccessToken.authenticate(token_string)
raise AccessTokenError unless @access_token
account = access_token_account(@domain_root_account, @access_token)
raise AccessTokenError unless @access_token.authorized_for_account?(account)
@current_user = @access_token.user
@real_current_user = @access_token.real_user
@real_current_pseudonym = SisPseudonym.for(@real_current_user, @domain_root_account, type: :implicit, require_sis: false) if @real_current_user
@current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)
@current_pseudonym = nil if (@current_pseudonym&.suspended? && !@real_current_pseudonym) || @real_current_pseudonym&.suspended?
raise AccessTokenError unless @current_user && @current_pseudonym
validate_scopes
@access_token.used!
RequestContext::Generator.add_meta_header('at', @access_token.global_id)
RequestContext::Generator.add_meta_header('dk', @access_token.global_developer_key_id) if @access_token.developer_key_id
end
end
def access_token_account(domain_root_account, access_token)
dev_key_account_id = access_token.dev_key_account_id
if dev_key_account_id.blank? || domain_root_account.id == dev_key_account_id
domain_root_account
else
get_context
(@context && Context.get_account(@context)) || domain_root_account
end
end
def load_user
@current_user = @current_pseudonym = nil
masked_authenticity_token # ensure that the cookie is set
load_pseudonym_from_jwt
load_pseudonym_from_access_token unless @current_pseudonym.present?
unless @current_pseudonym
if @policy_pseudonym_id
@current_pseudonym = Pseudonym.where(id: @policy_pseudonym_id).first
else
@pseudonym_session = PseudonymSession.find_with_validation
if @pseudonym_session
@current_pseudonym = @pseudonym_session.record
@current_pseudonym.user.reload if @current_pseudonym.shard != @current_pseudonym.user.shard
# if the session was created before the last time the user explicitly
# logged out (of any session for any of their pseudonyms), invalidate
# this session
invalid_before = @current_pseudonym.user.last_logged_out
# they logged out in the future?!? something's busted; just ignore it -
# either my clock is off or whoever set this value's clock is off
invalid_before = nil if invalid_before && invalid_before > Time.now.utc
if invalid_before &&
(session_refreshed_at = request.env['encrypted_cookie_store.session_refreshed_at']) &&
session_refreshed_at < invalid_before
logger.info "[AUTH] Invalidating session: Session created before user logged out."
invalidate_session
return
end
if @current_pseudonym &&
session[:cas_session] &&
@current_pseudonym.cas_ticket_expired?(session[:cas_session])
logger.info "[AUTH] Invalidating session: CAS ticket expired - #{session[:cas_session]}."
invalidate_session
return
end
if @current_pseudonym.suspended?
logger.info "[AUTH] Invalidating session: Pseudonym is suspended."
invalidate_session
return
end
end
end
if params[:login_success] == '1' && !@current_pseudonym
# they just logged in successfully, but we can't find the pseudonym now?
# sounds like somebody hates cookies.
return redirect_to(login_url(:needs_cookies => '1'))
end
@current_user = @current_pseudonym && @current_pseudonym.user
2011-02-01 09:57:29 +08:00
end
logger.info "[AUTH] inital load: pseud -> #{@current_pseudonym&.id}, user -> #{@current_user&.id}"
2011-02-01 09:57:29 +08:00
if @current_user && @current_user.unavailable?
logger.info "[AUTH] Invalid request: User is currently UNAVAILABLE"
2011-02-01 09:57:29 +08:00
@current_pseudonym = nil
@current_user = nil
2011-02-01 09:57:29 +08:00
end
# required by the user throttling middleware
session[:user_id] = @current_user.global_id if @current_user
if @current_user && %w(become_user_id me become_teacher become_student).any? { |k| params.key?(k) }
request_become_user = nil
2011-02-01 09:57:29 +08:00
if params[:become_user_id]
request_become_user = User.where(id: params[:become_user_id]).first
2011-02-01 09:57:29 +08:00
elsif params.keys.include?('me')
request_become_user = @current_user
2011-02-01 09:57:29 +08:00
elsif params.keys.include?('become_teacher')
course = Course.find(params[:course_id] || params[:id]) rescue nil
teacher = course.teachers.first if course
if teacher
request_become_user = teacher
2011-02-01 09:57:29 +08:00
else
flash[:error] = I18n.t('lib.auth.errors.teacher_not_found', "No teacher found")
2011-02-01 09:57:29 +08:00
end
elsif params.keys.include?('become_student')
course = Course.find(params[:course_id] || params[:id]) rescue nil
student = course.students.first if course
if student
request_become_user = student
2011-02-01 09:57:29 +08:00
else
flash[:error] = I18n.t('lib.auth.errors.student_not_found', "No student found")
2011-02-01 09:57:29 +08:00
end
end
if request_become_user && request_become_user.id != session[:become_user_id].to_i && request_become_user.can_masquerade?(@current_user, @domain_root_account)
params_without_become = params.dup
params_without_become.delete_if { |k, v| ['become_user_id', 'become_teacher', 'become_student', 'me'].include? k }
params_without_become[:only_path] = true
session[:masquerade_return_to] = url_for(params_without_become.to_unsafe_h)
return redirect_to user_masquerade_url(request_become_user.id)
end
2011-02-01 09:57:29 +08:00
end
as_user_id = api_request? && params[:as_user_id].presence
as_user_id ||= session[:become_user_id]
if as_user_id
begin
user = api_find(User, as_user_id)
rescue ActiveRecord::RecordNotFound
nil
end
if user && @real_current_user
if @current_user != user
# if we're already masquerading from an access token, and now try to
# masquerade as someone else
render :json => { :errors => "Cannot change masquerade" }, :status => :unauthorized
return false
# else: they do match, everything is already set
end
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url} via masquerade token"
elsif user&.can_masquerade?(@current_user, @domain_root_account)
@real_current_user = @current_user
@current_user = user
@real_current_pseudonym = @current_pseudonym
@current_pseudonym = SisPseudonym.for(@current_user, @domain_root_account, type: :implicit, require_sis: false)
logger.warn "[AUTH] #{@real_current_user.name}(#{@real_current_user.id}) impersonating #{@current_user.name} on page #{request.url}"
elsif api_request? # fail silently for UI, but not for API
result = { errors: "Invalid as_user_id" }
if user&.deleted? && user.merged_into_user_id && user.grants_right?(@current_user, :read)
result[:merged_into_user_id] = user.merged_into_user_id
end
# this should maybe be 404, not 401, but we can't change it now
render json: result, status: :unauthorized
return false
end
2011-02-01 09:57:29 +08:00
end
logger.info "[AUTH] final user: #{@current_user&.id}"
2011-02-01 09:57:29 +08:00
@current_user
end
private :load_user
2011-02-01 09:57:29 +08:00
def require_user
if @current_user && @current_pseudonym
true
else
redirect_to_login
false
2011-02-01 09:57:29 +08:00
end
end
protected :require_user
def require_non_jwt_auth
if @authenticated_with_jwt
render(
json: { error: "cannot generate a JWT when authorized by a JWT" },
status: 403
)
end
end
def clean_return_to(url)
return nil if url.blank?
begin
uri = URI.parse(url)
rescue URI::Error
return nil
end
return nil unless uri.path && uri.path[0] == '/'
return "#{request.protocol}#{request.host_with_port}#{uri.path.sub(%r{/download$}, '')}" if uri.path =~ %r{/files/(\d+~)?\d+/download$}
return "#{request.protocol}#{request.host_with_port}#{uri.path}#{uri.query && "?#{uri.query}"}#{uri.fragment && "##{uri.fragment}"}"
end
def return_to(url, fallback)
url = clean_return_to(url) || clean_return_to(fallback)
redirect_to url
end
def store_location(uri = nil, overwrite = true)
2011-02-01 09:57:29 +08:00
if overwrite || !session[:return_to]
uri ||= request.get? ? request.fullpath : request.referrer
session[:return_to] = clean_return_to(uri)
2011-02-01 09:57:29 +08:00
end
end
protected :store_location
def redirect_back_or_default(default)
session.delete(:return_to) || default
2011-02-01 09:57:29 +08:00
end
protected :redirect_back_or_default
def redirect_to_referrer_or_default(default)
redirect_back(fallback_location: default)
end
def redirect_to_login
return unless fix_ms_office_redirects
respond_to do |format|
format.json { render_json_unauthorized }
format.all do
store_location
flash[:warning] = I18n.t('lib.auth.errors.not_authenticated', "You must be logged in to access this page") unless request.path == '/'
redirect_to login_url(params.permit(:canvas_login, :authentication_provider))
end
end
end
def render_json_unauthorized
add_www_authenticate_header if api_request? && !@current_user
if @current_user
render json: {
status: I18n.t('lib.auth.status_unauthorized', 'unauthorized'),
errors: [{ message: I18n.t('lib.auth.not_authorized', "user not authorized to perform that action") }]
},
status: :unauthorized
else
render json: {
status: I18n.t('lib.auth.status_unauthenticated', 'unauthenticated'),
errors: [{ :message => I18n.t('lib.auth.authentication_required', "user authorization required") }]
},
status: :unauthorized
end
end
def add_www_authenticate_header
response['WWW-Authenticate'] = %{Bearer realm="canvas-lms"}
end
2011-02-01 09:57:29 +08:00
# Reset the session, and copy the specified keys over to the new session.
# Please consider the security implications of any keys you copy over.
def reset_session_saving_keys(*keys)
# can't use slice, because session has a different ctor than a normal hash
saved = {}
keys.each { |k| saved[k] = session[k] if session[k] }
2011-02-01 09:57:29 +08:00
reset_session
saved.each_pair { |k, v| session[k] = v }
2011-02-01 09:57:29 +08:00
end
def invalidate_session
destroy_session
@current_pseudonym = nil
raise LoggedOutError if api_request? || request.format.json?
redirect_to_login unless params[:controller]&.start_with?('login') && params[:action] == 'new'
end
2011-02-01 09:57:29 +08:00
end