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:
Ethan Vizitei 2015-10-28 11:28:27 -06:00 committed by Ethan Vizitei
parent 67b81ccda5
commit 5183710300
7 changed files with 202 additions and 26 deletions

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

17
spec/support/jwt_env.rb Normal file
View File

@ -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