From 7511243ef7f6280d2ad5fd31cac5968d16af65d2 Mon Sep 17 00:00:00 2001 From: Brent Burgoyne Date: Wed, 22 Feb 2017 09:16:49 -0700 Subject: [PATCH] add endpoint to refresh jwt with with workflows now that services jwts have a concept of workflows and extra data encoded in the token based on workflow requirements, the existing jwts endpoint is insufficient for refreshing expired jwts. to some extent it was already broken since it lost the context when refreshed with that endpoint a new refresh endpoint has been addded that takes an exisintg jwt as a param. this makes it possible to get a new token with the same workflows, state, and context as an existing expired token as long as the token matches your user and is requested with a valid session or oauth token. tokens may only be used for refresh up to six hours past expiration. refs CNVS-35199 test plan: - go to "Pages" in a couse with RCS enabled - open the console, and get the jwt from ENV.JWT - wait at least an hour - make a POST to [same-domain]/api/v1/jwts/refresh with the token as the `jwt` param - it should return a json response with a token property - copy the token - open up your rails console - run Canvas::Security.ServicesJwt.new("[copied token]").original_token - should return hash with the following proerties - should have :sub with your users global id - should have :domain that matches your canvas domain - should have :context_type of Course - should have :context_id of the course you generated the original token from - should have :workflows with rich_content and ui - repeat process masquerading as another user - when making a the post to the refresh endpoint use your user and set a param `as_user_id` to the id of the user you are masquerading as - the hash in the console should have - :sub with the global id of the user you are masquerading as - :masq_sub with your user id Change-Id: I399569ed8f2d3d0646728f72910456b77b3ed46a Reviewed-on: https://gerrit.instructure.com/102909 Reviewed-by: Tucker McKnight QA-Review: Tucker McKnight Tested-by: Jenkins Product-Review: Brent Burgoyne --- app/controllers/jwts_controller.rb | 62 ++++++++++-- config/routes.rb | 1 + lib/canvas/security.rb | 8 +- lib/canvas/security/services_jwt.rb | 64 +++++++++++- spec/controllers/jwts_controller_spec.rb | 77 +++++++++++++-- spec/lib/canvas/security/services_jwt_spec.rb | 99 +++++++++++++++++-- 6 files changed, 284 insertions(+), 27 deletions(-) diff --git a/app/controllers/jwts_controller.rb b/app/controllers/jwts_controller.rb index d6125c61ffb..fa2e21208e8 100644 --- a/app/controllers/jwts_controller.rb +++ b/app/controllers/jwts_controller.rb @@ -18,7 +18,7 @@ class JwtsController < ApplicationController - before_action :require_user + before_action :require_user, :require_non_jwt_auth # @API Create JWT # @@ -32,17 +32,63 @@ class JwtsController < ApplicationController # -X POST \ # -H "Accept: application/json" \ # -H 'Authorization: Bearer ' + # + # @returns JWT def create - if @authenticated_with_jwt - render( - json: {error: "cannot generate a JWT when authorized by a JWT"}, - status: 403 - ) - return false - end services_jwt = Canvas::Security::ServicesJwt. for_user(request.env['HTTP_HOST'], @current_user, real_user: @real_current_user) render json: { token: services_jwt } end + # @API Refresh JWT + # + # Refresh a JWT for use with other canvas services + # + # Generates a different JWT each time it's called, each one expires + # after a short window (1 hour). + # + # @argument jwt [Required, String] + # An existing JWT token to be refreshed. The new token will have + # the same context and workflows as the existing token. + # + # @example_request + # curl 'https:///api/v1/jwts/refresh' \ + # -X POST \ + # -H "Accept: application/json" \ + # -H 'Authorization: Bearer ' + # -d 'jwt=' + # + # @returns JWT + def refresh + if params[:jwt].nil? + return render( + json: {errors: {jwt: "required"}}, + status: 400 + ) + end + services_jwt = Canvas::Security::ServicesJwt.refresh_for_user( + params[:jwt], + request.env['HTTP_HOST'], + @current_user, + real_user: @real_current_user + ) + render json: { token: services_jwt } + rescue Canvas::Security::ServicesJwt::InvalidRefresh + render( + json: {errors: {jwt: "invalid refresh"}}, + status: 400 + ) + end + + private + + 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 + end diff --git a/config/routes.rb b/config/routes.rb index ea5fd663afe..8aabd6702a7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1881,6 +1881,7 @@ CanvasRails::Application.routes.draw do scope(controller: :jwts) do post 'jwts', action: :create + post 'jwts/refresh', action: :refresh end scope(controller: :gradebook_settings) do diff --git a/lib/canvas/security.rb b/lib/canvas/security.rb index 26a41473271..c8774d5cd97 100644 --- a/lib/canvas/security.rb +++ b/lib/canvas/security.rb @@ -167,13 +167,13 @@ module Canvas::Security raise Canvas::Security::InvalidToken end - def self.decrypt_services_jwt(token, signing_secret=nil, encryption_secret=nil) + def self.decrypt_services_jwt(token, signing_secret=nil, encryption_secret=nil, ignore_expiration: false) signing_secret ||= services_signing_secret encryption_secret ||= services_encryption_secret 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) + verify_jwt(raw_jwt, ignore_expiration: ignore_expiration) raw_jwt.with_indifferent_access rescue JSON::JWS::VerificationFailed raise Canvas::Security::InvalidToken @@ -317,8 +317,8 @@ module Canvas::Security class << self private - def verify_jwt(body) - if body[:exp].present? + def verify_jwt(body, ignore_expiration: false) + if body[:exp].present? && !ignore_expiration if timestamp_is_expired?(body[:exp]) raise Canvas::Security::TokenExpired end diff --git a/lib/canvas/security/services_jwt.rb b/lib/canvas/security/services_jwt.rb index b505d5fb7ba..b9c4dfa940c 100644 --- a/lib/canvas/security/services_jwt.rb +++ b/lib/canvas/security/services_jwt.rb @@ -1,4 +1,8 @@ class Canvas::Security::ServicesJwt + class InvalidRefresh < RuntimeError; end + + REFRESH_WINDOW = 6.hours + attr_reader :token_string, :is_wrapped def initialize(raw_token_string, wrapped=true) @@ -15,13 +19,18 @@ class Canvas::Security::ServicesJwt Canvas::Security.decode_jwt(raw_wrapper_token, [signing_secret]) end - def original_token + def original_token(ignore_expiration: false) original_crypted_token = if is_wrapped wrapper_token[:user_token] else Canvas::Security.base64_decode(token_string) end - Canvas::Security.decrypt_services_jwt(original_crypted_token, signing_secret, encryption_secret) + Canvas::Security.decrypt_services_jwt( + original_crypted_token, + signing_secret, + encryption_secret, + ignore_expiration: ignore_expiration + ) end def id @@ -69,6 +78,31 @@ class Canvas::Security::ServicesJwt generate(payload) end + def self.refresh_for_user(jwt, domain, user, real_user: nil) + begin + payload = new(jwt, false).original_token(ignore_expiration: true) + rescue JSON::JWT::InvalidFormat + raise InvalidRefresh, "invalid token" + end + + if refresh_invalid_for_user?(payload, domain, user, real_user) + raise InvalidRefresh, "token does not match user and domain" + end + + if past_refresh_window?(payload[:exp]) + raise InvalidRefresh, "refresh window exceeded" + end + + if payload[:context_type].present? + context = payload[:context_type].constantize.find(payload[:context_id]) + end + + for_user(domain, user, + real_user: real_user, + workflows: payload[:workflows], + context: context) + end + private def self.create_payload(payload_data) @@ -101,4 +135,30 @@ class Canvas::Security::ServicesJwt def self.signing_secret Canvas::DynamicSettings.from_cache("canvas")["signing-secret"] end + + class << self + private + + def refresh_invalid_for_user?(payload, domain, user, real_user) + invalid_user = payload[:sub] != user.global_id + invalid_domain = payload[:domain] != domain + if payload[:masq_sub].present? + invalid_real = real_user.nil? || payload[:masq_sub] != real_user.global_id + else + invalid_real = real_user.present? + end + invalid_user || invalid_domain || invalid_real + end + + def past_refresh_window?(exp) + if exp.is_a?(Time) + refresh_exp = exp + REFRESH_WINDOW + now = Time.zone.now + else + refresh_exp = exp + REFRESH_WINDOW.to_i + now = Time.zone.now.to_i + end + refresh_exp <= now + end + end end diff --git a/spec/controllers/jwts_controller_spec.rb b/spec/controllers/jwts_controller_spec.rb index d9687c4f7fb..5dfc753be0d 100644 --- a/spec/controllers/jwts_controller_spec.rb +++ b/spec/controllers/jwts_controller_spec.rb @@ -1,12 +1,19 @@ require_relative '../spec_helper' describe JwtsController do + include_context "JWT setup" + let(:token_user){ user_factory(active_user: true) } + let(:other_user){ user_factory(active_user: true) } + let(:translate_token) do + ->(resp){ + un_csrfd_body = resp.body.gsub("while(1);", "") + utf8_token_string = JSON.parse(un_csrfd_body)['token'] + decoded_crypted_token = Canvas::Security.base64_decode(utf8_token_string) + return Canvas::Security.decrypt_services_jwt(decoded_crypted_token) + } + end + describe "#generate" do - include_context "JWT setup" - - let(:token_user){ user_factory(active_user: true) } - let(:other_user){ user_factory(active_user: true) } - it "requires being logged in" do post 'create' expect(response).to be_redirect @@ -14,7 +21,6 @@ describe JwtsController do end context "with valid user session" do - before(:each){ user_session(token_user) } let(:translate_token) do ->(resp){ @@ -36,7 +42,6 @@ describe JwtsController do decrypted_token_body = translate_token.call(response) expect(decrypted_token_body[:domain]).to eq("test.host") end - end it "doesn't allow using a token to gen a token" do @@ -44,6 +49,64 @@ describe JwtsController do get 'create', {format: 'json'}, {'Authorization' => "Bearer #{token}"} expect(response.status).to_not eq(200) end + end + describe "#refresh" do + it "requires being logged in" do + post 'refresh' + expect(response).to be_redirect + expect(response.status).to eq(302) + end + + it "doesn't allow using a token to gen a token" do + token = Canvas::Security::ServicesJwt.generate({ sub: token_user.global_id }) + get 'refresh', {format: 'json'}, {'Authorization' => "Bearer #{token}"} + expect(response.status).to_not eq(200) + end + + context "with valid user session" do + before(:each) do + user_session(token_user) + request.env['HTTP_HOST'] = 'testhost' + end + + it "requires a jwt param" do + post 'refresh' + expect(response.status).to_not eq(200) + end + + it "returns a refreshed token for user" do + real_user = site_admin_user(active_user: true) + user_with_pseudonym(:user => other_user, :username => "other@example.com") + user_session(real_user) + services_jwt = class_double(Canvas::Security::ServicesJwt).as_stubbed_const + expect(services_jwt).to receive(:refresh_for_user) + .with('testjwt', 'testhost', other_user, real_user: real_user) + .and_return('refreshedjwt') + post 'refresh', format: 'json', jwt: 'testjwt', as_user_id: other_user.id + token = JSON.parse(response.body)['token'] + expect(token).to eq('refreshedjwt') + end + + it "returns a different jwt when refresh is called" do + course = course_factory + original_jwt = Canvas::Security::ServicesJwt.for_user( + request.env['HTTP_HOST'], + token_user + ) + post 'refresh', jwt: original_jwt + refreshed_jwt = JSON.parse(response.body)['token'] + expect(refreshed_jwt).to_not eq(original_jwt) + end + + it "returns an error if jwt is invalid for refresh" do + services_jwt = class_double(Canvas::Security::ServicesJwt) + .as_stubbed_const(transfer_nested_constants: true) + expect(services_jwt).to receive(:refresh_for_user) + .and_raise(Canvas::Security::ServicesJwt::InvalidRefresh) + post 'refresh', format: 'json', jwt: 'testjwt' + expect(response.status).to eq(400) + end + end end end diff --git a/spec/lib/canvas/security/services_jwt_spec.rb b/spec/lib/canvas/security/services_jwt_spec.rb index b3093b73606..ea8f6b06a76 100644 --- a/spec/lib/canvas/security/services_jwt_spec.rb +++ b/spec/lib/canvas/security/services_jwt_spec.rb @@ -18,6 +18,13 @@ module Canvas::Security Canvas::Security.base64_encode(wrapper_token) end + let(:translate_token) do + ->(jwt){ + decoded_crypted_token = Canvas::Security.base64_decode(jwt) + return Canvas::Security.decrypt_services_jwt(decoded_crypted_token) + } + end + it "has secrets accessors" do expect(ServicesJwt.encryption_secret).to eq(fake_encryption_secret) expect(ServicesJwt.signing_secret).to eq(fake_signing_secret) @@ -114,12 +121,6 @@ module Canvas::Security let(:ctx){ stub(id: 47) } let(:host){ "example.instructure.com" } let(:masq_user){ stub(global_id: 24) } - let(:translate_token) do - ->(jwt){ - decoded_crypted_token = Canvas::Security.base64_decode(jwt) - return Canvas::Security.decrypt_services_jwt(decoded_crypted_token) - } - end it "can build from a user and host" do jwt = ServicesJwt.for_user(host, user) @@ -197,6 +198,92 @@ module Canvas::Security to raise_error(ArgumentError) end end + + describe "refresh_for_user" do + let(:user1){ stub(global_id: 42) } + let(:user2){ stub(global_id: 43) } + let(:host) { 'testhost' } + + it 'is invalid if jwt cannot be decoded' do + expect{ ServicesJwt.refresh_for_user('invalidjwt', host, user1) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + + it 'is invlaid if user id is different' do + jwt = ServicesJwt.for_user(host, user1) + expect{ ServicesJwt.refresh_for_user(jwt, host, user2) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + + it 'is invlaid if host is different' do + jwt = ServicesJwt.for_user('differenthost', user1) + expect{ ServicesJwt.refresh_for_user(jwt, host, user1) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + + it 'is invlaid masquerading user is different' do + masq_user = stub(global_id: 44) + jwt = ServicesJwt.for_user(host, user1, real_user: masq_user) + expect{ ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + + it 'is invalid if masquerading and token does not have masq_sub' do + jwt = ServicesJwt.for_user(host, user1) + expect{ ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + + it 'is invalid if more than 6 hours past token expiration' do + jwt = ServicesJwt.for_user(host, user1) + Timecop.freeze(7.hours.from_now) do + expect{ ServicesJwt.refresh_for_user(jwt, host, user1) } + .to raise_error(ServicesJwt::InvalidRefresh) + end + end + + it 'generates a token with the same user id and host' do + jwt = ServicesJwt.for_user(host, user1) + refreshed = ServicesJwt.refresh_for_user(jwt, host, user1) + payload = translate_token.call(refreshed) + expect(payload[:sub]).to eq(user1.global_id) + expect(payload[:domain]).to eq(host) + expect(payload[:masq_sub]).to be_nil + end + + it 'generates a token with masq_sub for masquerading users' do + jwt = ServicesJwt.for_user(host, user1, real_user: user2) + refreshed = ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2) + payload = translate_token.call(refreshed) + expect(payload[:masq_sub]).to eq(user2.global_id) + end + + it 'generates a token with same workflows as original' do + workflows = ['rich_content', 'ui'] + jwt = ServicesJwt.for_user(host, user1, workflows: workflows) + refreshed = ServicesJwt.refresh_for_user(jwt, host, user1) + payload = translate_token.call(refreshed) + expect(payload[:workflows]).to eq(workflows) + end + + it 'generates a token with same context as original' do + context = course_factory + jwt = ServicesJwt.for_user(host, user1, context: context) + refreshed = ServicesJwt.refresh_for_user(jwt, host, user1) + payload = translate_token.call(refreshed) + expect(payload[:context_type]).to eq(context.class.name) + expect(payload[:context_id]).to eq(context.id.to_s) + end + + it 'generates a new token even if the original token has expired' do + jwt = ServicesJwt.for_user(host, user1) + Timecop.freeze(61.minutes.from_now) do + refreshed = ServicesJwt.refresh_for_user(jwt, host, user1) + payload = translate_token.call(refreshed) + expect(payload[:sub]).to eq(user1.global_id) + end + end + end end end end