canvas-lms/lib/canvas/security.rb

322 lines
11 KiB
Ruby

#
# Copyright (C) 2011 Instructure, Inc.
#
# 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/>.
#
require 'json/jwt'
module Canvas::Security
class InvalidToken < RuntimeError
end
class TokenExpired < RuntimeError
end
def self.encryption_key
@encryption_key ||= begin
res = config && config['encryption_key']
abort('encryption key required, see security.yml.example') unless res
abort('encryption key is too short, see security.yml.example') unless res.to_s.length >= 20
res.to_s
end
end
def self.encryption_keys
@encryption_keys ||= [encryption_key] + Array(config && config['previous_encryption_keys']).map(&:to_s)
end
def self.config
@config ||= (YAML.load_file(Rails.root+"config/security.yml")[Rails.env] rescue nil)
end
def self.encrypt_password(secret, key)
require 'base64'
c = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
c.encrypt
c.key = Digest::SHA1.hexdigest(key + "_" + encryption_key)
c.iv = iv = c.random_iv
e = c.update(secret)
e << c.final
[Base64.encode64(e), Base64.encode64(iv)]
end
def self.decrypt_password(secret, salt, key, encryption_key = nil)
require 'base64'
encryption_keys = Array(encryption_key) + self.encryption_keys
last_error = nil
encryption_keys.each do |encryption_key|
c = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
c.decrypt
c.key = Digest::SHA1.hexdigest(key + "_" + encryption_key)
c.iv = Base64.decode64(salt)
d = c.update(Base64.decode64(secret))
begin
d << c.final
rescue OpenSSL::Cipher::CipherError
last_error = $!
next
end
return d.to_s
end
raise last_error
end
def self.hmac_sha1(str, encryption_key = nil)
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha1'), (encryption_key || self.encryption_key), str
)
end
def self.verify_hmac_sha1(hmac, str, options = {})
keys = options[:keys] || []
keys += [options[:key]] if options[:key]
keys += encryption_keys
keys.each do |key|
real_hmac = hmac_sha1(str, key)
real_hmac = real_hmac[0, options[:truncate]] if options[:truncate]
return true if hmac == real_hmac
end
false
end
# Creates a JWT token string
#
# body (Hash) - The contents of the JWT token
# expires (Time) - When the token should expire. `nil` for no expiration
# key (String) - The key to sign with. `nil` will use the currently configured key
#
# Returns the token as a string.
def self.create_jwt(body, expires = nil, key = nil)
jwt_body = body
if expires
jwt_body = jwt_body.merge({ exp: expires.to_i })
end
raw_jwt = JSON::JWT.new(jwt_body)
return raw_jwt.to_s if key == :unsigned
raw_jwt.sign(key || encryption_key, :HS256).to_s
end
# Creates an encrypted JWT token string
#
# This is a token that will be used for identifying the user to
# canvas on API calls and to other canvas-ecosystem services.
#
# payload (hash) - The data you want in the token
# signing_secret (big string) - The shared secret for signing
# encryption_secret (big string) - The shared key for symmetric key encryption.
#
# Returns the token as a string.
def self.create_encrypted_jwt(payload, signing_secret, encryption_secret)
jwt = JSON::JWT.new(payload)
jws = jwt.sign(signing_secret, :HS256)
jwe = jws.encrypt(encryption_secret, 'dir', :A256GCM)
jwe.to_s
end
# Verifies and decodes a JWT token
#
# token (String) - The token to decode
# keys (Array) - An array of keys to use verifying. Will be added to the current
# set of keys
#
# Returns the token body as a Hash if it's valid.
#
# Raises Canvas::Security::TokenExpired if the token has expired, and
# Canvas::Security::InvalidToken if the token is otherwise invalid.
def self.decode_jwt(token, keys = [])
keys += encryption_keys
keys.each do |key|
begin
body = JSON::JWT.decode(token, key)
verify_jwt(body)
return body.with_indifferent_access
rescue JSON::JWS::VerificationFailed
# Keep looping, to try all the keys. If none succeed,
# we raise below.
rescue Canvas::Security::TokenExpired => expired_exception
raise expired_exception
rescue => e
raise Canvas::Security::InvalidToken, e
end
end
raise Canvas::Security::InvalidToken
end
def self.decrypt_services_jwt(token, signing_secret=nil, encryption_secret=nil)
signing_secret ||= ENV['ECOSYSTEM_SECRET']
encryption_secret ||= ENV['ECOSYSTEM_KEY']
begin
signed_coded_jwt = JSON::JWT.decode(token, encryption_secret)
raw_jwt = JSON::JWT.decode(signed_coded_jwt.plain_text, signing_secret)
verify_jwt(raw_jwt)
raw_jwt.with_indifferent_access
rescue JSON::JWS::VerificationFailed
raise Canvas::Security::InvalidToken
end
end
def self.base64_encode(token_string)
Base64.encode64(token_string).encode('utf-8').delete("\n")
end
def self.base64_decode(token_string)
utf8_string = token_string.force_encoding(Encoding::UTF_8)
Base64.decode64(utf8_string.encode('ascii-8bit'))
end
def self.validate_encryption_key(overwrite = false)
db_hash = Setting.get('encryption_key_hash', nil) rescue return # in places like rake db:test:reset, we don't care that the db/table doesn't exist
return if encryption_keys.any? { |key| Digest::SHA1.hexdigest(key) == db_hash}
if db_hash.nil? || overwrite
begin
Setting.set("encryption_key_hash", Digest::SHA1.hexdigest(encryption_key))
rescue ActiveRecord::StatementInvalid
# the db may not exist yet
end
else
abort "encryption key is incorrect. if you have intentionally changed it, you may want to run `rake db:reset_encryption_key_hash`"
end
end
def self.re_encrypt_data(encryption_key)
{
Account => {
:encrypted_column => :turnitin_crypted_secret,
:salt_column => :turnitin_salt,
:key => 'instructure_turnitin_secret_shared' },
AccountAuthorizationConfig => {
:encrypted_column => :auth_crypted_password,
:salt_column => :auth_password_salt,
:key => 'instructure_auth' },
UserService => {
:encrypted_column => :crypted_password,
:salt_column => :password_salt,
:key => 'instructure_user_service' },
User => {
:encrypted_column => :otp_secret_key_enc,
:salt_column => :otp_secret_key_salt,
:key => 'otp_secret_key'
}
}.each do |(model, definition)|
model.where("#{definition[:encrypted_column]} IS NOT NULL").
select([:id, definition[:encrypted_column], definition[:salt_column]]).
find_each do |instance|
cleartext = Canvas::Security.decrypt_password(instance.read_attribute(definition[:encrypted_column]),
instance.read_attribute(definition[:salt_column]),
definition[:key],
encryption_key)
new_crypted_data, new_salt = Canvas::Security.encrypt_password(cleartext, definition[:key])
model.where(:id => instance).
update_all(definition[:encrypted_column] => new_crypted_data,
definition[:salt_column] => new_salt)
end
end
PluginSetting.find_each do |settings|
unless settings.plugin
warn "Unknown plugin #{settings.name}"
next
end
Array(settings.plugin.encrypted_settings).each do |setting|
cleartext = Canvas::Security.decrypt_password(settings.settings["#{setting}_enc".to_sym],
settings.settings["#{setting}_salt".to_sym],
'instructure_plugin_setting',
encryption_key)
new_crypted_data, new_salt = Canvas::Security.encrypt_password(cleartext, 'instructure_plugin_setting')
settings.settings["#{setting}_enc".to_sym] = new_crypted_data
settings.settings["#{setting}_salt".to_sym] = new_salt
settings.settings_will_change!
end
settings.save! if settings.changed?
end
end
# should we allow this login attempt -- returns false if there have been too
# many recent failed attempts for this pseudonym. Failed attempts are tracked
# by both (pseudonym) and (pseudonym, requesting_ip) , with the latter having
# a lower threshold. This way a malicious user can't trivially lock out
# another user by just making a bunch of bogus requests, they'll be blocked
# themselves first. A distributed attack would still succeed in locking out
# the user.
#
# in redis this is stored as a hash :
# { 'unique_id' => pseudonym.unique_id, # for debugging
# 'total' => <total failed attempts>,
# some_ip => <failed attempts for this ip>,
# some_other_ip => <failed attempts for this ip>,
# ...
# }
def self.allow_login_attempt?(pseudonym, ip)
return true unless Canvas.redis_enabled? && pseudonym
ip.present? || ip = 'no_ip'
total_allowed = Setting.get('login_attempts_total', '20').to_i
ip_allowed = Setting.get('login_attempts_per_ip', '10').to_i
total, from_this_ip = Canvas.redis.hmget(login_attempts_key(pseudonym), 'total', ip)
return (!total || total.to_i < total_allowed) && (!from_this_ip || from_this_ip.to_i < ip_allowed)
end
# log a successful login, resetting the failed login attempts counter
def self.successful_login!(pseudonym, ip)
return unless Canvas.redis_enabled? && pseudonym
Canvas.redis.del(login_attempts_key(pseudonym))
nil
end
# log a failed login attempt
def self.failed_login!(pseudonym, ip)
return unless Canvas.redis_enabled? && pseudonym
key = login_attempts_key(pseudonym)
exptime = Setting.get('login_attempts_ttl', 5.minutes.to_s).to_i
redis = Canvas.redis
redis.hset(key, 'unique_id', pseudonym.unique_id)
redis.hincrby(key, 'total', 1)
redis.hincrby(key, ip, 1) if ip.present?
redis.expire(key, exptime)
nil
end
# returns time in seconds
def self.time_until_login_allowed(pseudonym, ip)
if self.allow_login_attempt?(pseudonym, ip)
0
else
Canvas.redis.ttl(login_attempts_key(pseudonym))
end
end
def self.login_attempts_key(pseudonym)
"login_attempts:#{pseudonym.global_id}"
end
private
def self.verify_jwt(body)
if body[:exp].present?
if timestamp_is_exipred?(body[:exp])
raise Canvas::Security::TokenExpired
end
end
end
def self.timestamp_is_exipred?(exp_val)
now = Time.zone.now
(exp_val.is_a?(Time) && exp_val <= now) || exp_val <= now.to_i
end
end