Add a JWT endpoint for generating services tokens
closes CNVS-24285 This token isn't consumed by anyone yet, ultimately it will be used to be passed back to canvas from other trusted services to prove the user is authorized to those services and assets. TEST PLAN: 1) login as a user 2) visit /jwts/generate 3) you should see a token-like thing in the JSON Change-Id: I95852758597606d4ee3f1d2c788fcb252e7c154a Reviewed-on: https://gerrit.instructure.com/65983 Tested-by: Jenkins Reviewed-by: Mike Nomitch <mnomitch@instructure.com> QA-Review: August Thornton <august@instructure.com> Product-Review: Ethan Vizitei <evizitei@instructure.com>
This commit is contained in:
parent
67b81ccda5
commit
5183710300
|
@ -0,0 +1,18 @@
|
|||
class JwtsController < ApplicationController
|
||||
|
||||
before_filter :require_user
|
||||
|
||||
def generate
|
||||
if @authenticated_with_jwt
|
||||
render(
|
||||
json: {error: "cannot generate a JWT when authorized by a JWT"},
|
||||
status: 403
|
||||
)
|
||||
return false
|
||||
end
|
||||
crypted_token = Canvas::Security.create_services_jwt(@current_user.global_id)
|
||||
utf8_crypted_token = Canvas::Security.base64_encode(crypted_token)
|
||||
render json: { token: utf8_crypted_token }
|
||||
end
|
||||
|
||||
end
|
|
@ -1770,6 +1770,8 @@ CanvasRails::Application.routes.draw do
|
|||
get 'login/oauth2/deny' => 'oauth2_provider#deny', as: :oauth2_auth_deny
|
||||
delete 'login/oauth2/token' => 'oauth2_provider#destroy', as: :oauth2_logout
|
||||
|
||||
get 'jwts/generate' => 'jwts#generate'
|
||||
|
||||
ApiRouteSet.draw(self, "/api/lti/v1") do
|
||||
post "tools/:tool_id/grade_passback", controller: :lti_api, action: :grade_passback, as: "lti_grade_passback_api"
|
||||
post "tools/:tool_id/ext_grade_passback", controller: :lti_api, action: :legacy_grade_passback, as: "blti_legacy_grade_passback_api"
|
||||
|
|
|
@ -38,6 +38,8 @@ web:
|
|||
environment:
|
||||
RACK_ENV: development
|
||||
VIRTUAL_HOST: .canvas.docker
|
||||
ECOSYSTEM_SECRET: 7hZoK4DZyrH+A7nxLhC=fVeRQq79oCqq]QK92M?LGPh/tKQv]Q
|
||||
ECOSYSTEM_KEY: +h+oJTJ)(AcfUxGNUy7Pk2zRTwPaTeqA;&MQ3Gx6936VfvHumn
|
||||
|
||||
jobs:
|
||||
build: ./docker-compose
|
||||
|
|
|
@ -109,6 +109,26 @@ module Canvas::Security
|
|||
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.
|
||||
#
|
||||
# user_global_id (int) - The globally unique id of the user this token represents
|
||||
# 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_services_jwt(user_global_id, signing_secret=nil, encryption_secret=nil)
|
||||
signing_secret ||= ENV['ECOSYSTEM_SECRET']
|
||||
encryption_secret ||= ENV['ECOSYSTEM_KEY']
|
||||
payload = jwt_payload_for_user(user_global_id, 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
|
||||
|
@ -125,12 +145,7 @@ module Canvas::Security
|
|||
keys.each do |key|
|
||||
begin
|
||||
body = JSON::JWT.decode(token, key)
|
||||
if body[:exp].present?
|
||||
if ((body[:exp].is_a?(Time) && body[:exp] <= Time.zone.now) ||
|
||||
body[:exp] <= Time.zone.now.to_i)
|
||||
raise Canvas::Security::TokenExpired
|
||||
end
|
||||
end
|
||||
verify_jwt(body)
|
||||
return body.with_indifferent_access
|
||||
rescue JSON::JWS::VerificationFailed
|
||||
# Keep looping, to try all the keys. If none succeed,
|
||||
|
@ -141,6 +156,28 @@ module Canvas::Security
|
|||
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}
|
||||
|
@ -265,4 +302,33 @@ module Canvas::Security
|
|||
def self.login_attempts_key(pseudonym)
|
||||
"login_attempts:#{pseudonym.global_id}"
|
||||
end
|
||||
|
||||
private
|
||||
def self.jwt_payload_for_user(user_global_id, encryption_secret)
|
||||
timestamp = Time.zone.now.to_i
|
||||
jti_input_string = [encryption_secret, timestamp, user_global_id].join(":")
|
||||
jti = Digest::MD5.hexdigest(jti_input_string)
|
||||
{
|
||||
iss: "Canvas",
|
||||
aud: ["Instructure"],
|
||||
exp: timestamp + 3600, # token is good for 1 hour
|
||||
nbf: timestamp - 30, # don't accept the token in the past
|
||||
iat: timestamp, # tell when the token was issued
|
||||
jti: jti, # unique identifier
|
||||
sub: user_global_id
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
||||
|
||||
describe JwtsController do
|
||||
describe "#generate" do
|
||||
include_context "JWT setup"
|
||||
|
||||
it "requires being logged in" do
|
||||
get 'generate'
|
||||
expect(response).to be_redirect
|
||||
expect(response.status).to eq(302)
|
||||
end
|
||||
|
||||
it "generates a base64 encoded token for a user session with env var secrets" do
|
||||
token_user = user(active_user: true)
|
||||
user_session(token_user)
|
||||
get 'generate', format: 'json'
|
||||
un_csrfd_body = response.body.gsub("while(1);", "")
|
||||
utf8_token_string = JSON.parse(un_csrfd_body)['token']
|
||||
decoded_crypted_token = Canvas::Security.base64_decode(utf8_token_string)
|
||||
decrypted_token_body = Canvas::Security.decrypt_services_jwt(decoded_crypted_token)
|
||||
expect(decrypted_token_body[:sub]).to eq(token_user.global_id)
|
||||
end
|
||||
|
||||
it "doesn't allow using a token to gen a token" do
|
||||
token_user = user(active_user: true)
|
||||
token = Canvas::Security.create_services_jwt(token_user.global_id)
|
||||
utf8_crypted_token = Canvas::Security.base64_encode(token)
|
||||
get 'generate', {format: 'json'}, {'Authorization' => "Bearer #{utf8_crypted_token}"}
|
||||
expect(response.status).to_not eq(200)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -25,30 +25,69 @@ describe Canvas::Security do
|
|||
Timecop.return
|
||||
end
|
||||
|
||||
it "should generate a token with an expiration" do
|
||||
Timecop.freeze(Time.utc(2013,3,13,9,12))
|
||||
expires = 1.hour.from_now
|
||||
token = Canvas::Security.create_jwt({ a: 1 }, expires)
|
||||
expect(token).to eq "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoxLCJleHAiOjEzNjMxNjk1MjB9.VwDKl46gfjFLPAIDwlkVPze1UwC6H_ApdyWYoUXFT8M"
|
||||
describe ".create_jwt" do
|
||||
it "should generate a token with an expiration" do
|
||||
Timecop.freeze(Time.utc(2013,3,13,9,12))
|
||||
expires = 1.hour.from_now
|
||||
token = Canvas::Security.create_jwt({ a: 1 }, expires)
|
||||
expected_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."\
|
||||
"eyJhIjoxLCJleHAiOjEzNjMxNjk1MjB9."\
|
||||
"VwDKl46gfjFLPAIDwlkVPze1UwC6H_ApdyWYoUXFT8M"
|
||||
expect(token).to eq(expected_token)
|
||||
end
|
||||
|
||||
it "should generate a token without expiration" do
|
||||
token = Canvas::Security.create_jwt({ a: 1 })
|
||||
expected_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."\
|
||||
"eyJhIjoxfQ."\
|
||||
"Pr4RQfnytL0LMwQ0pJXiKoHmEGAYw2OW3pYJTQM4d9I"
|
||||
expect(token).to eq(expected_token)
|
||||
end
|
||||
|
||||
it "should encode with configured encryption key" do
|
||||
jwt = stub
|
||||
jwt.expects(:sign).with(Canvas::Security.encryption_key, :HS256).returns("sometoken")
|
||||
JSON::JWT.stubs(new: jwt)
|
||||
Canvas::Security.create_jwt({ a: 1 })
|
||||
end
|
||||
|
||||
it "should encode with the supplied key" do
|
||||
jwt = stub
|
||||
jwt.expects(:sign).with("mykey", :HS256).returns("sometoken")
|
||||
JSON::JWT.stubs(new: jwt)
|
||||
Canvas::Security.create_jwt({ a: 1 }, nil, "mykey")
|
||||
end
|
||||
end
|
||||
|
||||
it "should generate a token without expiration" do
|
||||
token = Canvas::Security.create_jwt({ a: 1 })
|
||||
expect(token).to eq "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.Pr4RQfnytL0LMwQ0pJXiKoHmEGAYw2OW3pYJTQM4d9I"
|
||||
end
|
||||
describe ".create_services_jwt" do
|
||||
include_context "JWT setup"
|
||||
|
||||
it "should encode with configured encryption key" do
|
||||
jwt = stub
|
||||
jwt.expects(:sign).with(Canvas::Security.encryption_key, :HS256).returns("sometoken")
|
||||
JSON::JWT.stubs(new: jwt)
|
||||
Canvas::Security.create_jwt({ a: 1 })
|
||||
end
|
||||
it "builds up an encrypted token" do
|
||||
jwt = Canvas::Security.create_services_jwt(1, signing_secret, encryption_secret)
|
||||
expect(jwt.length).to eq(435)
|
||||
end
|
||||
|
||||
it "should encode with the supplied key" do
|
||||
jwt = stub
|
||||
jwt.expects(:sign).with("mykey", :HS256).returns("sometoken")
|
||||
JSON::JWT.stubs(new: jwt)
|
||||
Canvas::Security.create_jwt({ a: 1 }, nil, "mykey")
|
||||
it "builds up a token from env vars if no secrets passed" do
|
||||
env_var_jwt = Canvas::Security.create_services_jwt(1)
|
||||
expect(env_var_jwt.length).to eq(435)
|
||||
end
|
||||
|
||||
it "expires in an hour" do
|
||||
jwt = Canvas::Security.create_services_jwt(1, signing_secret, encryption_secret)
|
||||
jwt_body = Canvas::Security.decrypt_services_jwt(jwt, signing_secret, encryption_secret)
|
||||
expect(jwt_body[:exp]).to eq(1363169520)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".base64_encode" do
|
||||
it "trims off newlines" do
|
||||
input = "SuperSuperSuperSuperSuperSuperSuperSuper"\
|
||||
"SuperSuperSuperSuperSuperSuperSuperSuperLongString"
|
||||
output = "U3VwZXJTdXBlclN1cGVyU3VwZXJTdXBlclN1cGVy"\
|
||||
"U3VwZXJTdXBlclN1cGVyU3VwZXJTdXBlclN1cGVy"\
|
||||
"U3VwZXJTdXBlclN1cGVyU3VwZXJMb25nU3RyaW5n"
|
||||
expect(Canvas::Security.base64_encode(input)).to eq(output)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
RSpec.shared_context "JWT setup" do
|
||||
let(:signing_secret){ "asdfasdfasdfasdfasdfasdfasdfasdf" }
|
||||
let(:encryption_secret){ "jkl;jkl;jkl;jkl;jkl;jkl;jkl;jkl;" }
|
||||
|
||||
before do
|
||||
Timecop.freeze(Time.utc(2013,3,13,9,12))
|
||||
@preexisting_signing_secret = ENV['ECOSYSTEM_SECRET']
|
||||
@preexisting_encryption_secret = ENV['ECOSYSTEM_KEY']
|
||||
ENV['ECOSYSTEM_SECRET'] = signing_secret
|
||||
ENV['ECOSYSTEM_KEY'] = encryption_secret
|
||||
end
|
||||
|
||||
after do
|
||||
ENV['ECOSYSTEM_SECRET'] = @preexisting_signing_secret
|
||||
ENV['ECOSYSTEM_KEY'] = @preexisting_encryption_secret
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue