From 4c63c631922b4b86ad4d3a5f61104d1455d046b2 Mon Sep 17 00:00:00 2001 From: Krasimir Angelov Date: Wed, 4 Sep 2019 11:03:20 +1200 Subject: Extract Workhorse <-> GitLab authentication to make it reusable Introduce JWTAutheticatable module that can be reused for ai=uthtication between Pages and GitLab (the same way we use do now for Workhorse). Related to https://gitlab.com/gitlab-org/gitlab-ce/issues/61927. --- lib/gitlab/jwt_authenticatable.rb | 42 +++++++++++++ lib/gitlab/workhorse.rb | 28 +-------- spec/lib/gitlab/jwt_authenticatable_spec.rb | 93 +++++++++++++++++++++++++++++ spec/lib/gitlab/workhorse_spec.rb | 51 ---------------- 4 files changed, 137 insertions(+), 77 deletions(-) create mode 100644 lib/gitlab/jwt_authenticatable.rb create mode 100644 spec/lib/gitlab/jwt_authenticatable_spec.rb diff --git a/lib/gitlab/jwt_authenticatable.rb b/lib/gitlab/jwt_authenticatable.rb new file mode 100644 index 00000000000..1270a148e8d --- /dev/null +++ b/lib/gitlab/jwt_authenticatable.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module JwtAuthenticatable + # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 + # bytes https://tools.ietf.org/html/rfc4868#section-2.6 + SECRET_LENGTH = 32 + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + include Gitlab::Utils::StrongMemoize + + def decode_jwt_for_issuer(issuer, encoded_message) + JWT.decode( + encoded_message, + secret, + true, + { iss: issuer, verify_iss: true, algorithm: 'HS256' } + ) + end + + def secret + strong_memoize(:secret) do + Base64.strict_decode64(File.read(secret_path).chomp).tap do |bytes| + raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH + end + end + end + + def write_secret + bytes = SecureRandom.random_bytes(SECRET_LENGTH) + File.open(secret_path, 'w:BINARY', 0600) do |f| + f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op. + f.write(Base64.strict_encode64(bytes)) + end + end + end + end +end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 29087d26007..139ec6e384a 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -15,9 +15,7 @@ module Gitlab ALLOWED_GIT_HTTP_ACTIONS = %w[git_receive_pack git_upload_pack info_refs].freeze DETECT_HEADER = 'Gitlab-Workhorse-Detect-Content-Type'.freeze - # Supposedly the effective key size for HMAC-SHA256 is 256 bits, i.e. 32 - # bytes https://tools.ietf.org/html/rfc4868#section-2.6 - SECRET_LENGTH = 32 + include JwtAuthenticatable class << self def git_http_ok(repository, repo_type, user, action, show_all_refs: false) @@ -187,34 +185,12 @@ module Gitlab path.readable? ? path.read.chomp : 'unknown' end - def secret - @secret ||= begin - bytes = Base64.strict_decode64(File.read(secret_path).chomp) - raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH - - bytes - end - end - - def write_secret - bytes = SecureRandom.random_bytes(SECRET_LENGTH) - File.open(secret_path, 'w:BINARY', 0600) do |f| - f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op. - f.write(Base64.strict_encode64(bytes)) - end - end - def verify_api_request!(request_headers) decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER]) end def decode_jwt(encoded_message) - JWT.decode( - encoded_message, - secret, - true, - { iss: 'gitlab-workhorse', verify_iss: true, algorithm: 'HS256' } - ) + decode_jwt_for_issuer('gitlab-workhorse', encoded_message) end def secret_path diff --git a/spec/lib/gitlab/jwt_authenticatable_spec.rb b/spec/lib/gitlab/jwt_authenticatable_spec.rb new file mode 100644 index 00000000000..0c1c491b308 --- /dev/null +++ b/spec/lib/gitlab/jwt_authenticatable_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::JwtAuthenticatable do + let(:test_class) do + Class.new do + include Gitlab::JwtAuthenticatable + + def self.secret_path + Rails.root.join('tmp', 'tests', '.jwt_shared_secret') + end + end + end + + before do + begin + File.delete(test_class.secret_path) + rescue Errno::ENOENT + end + + test_class.write_secret + end + + describe '.secret' do + subject(:secret) { test_class.secret } + + it 'returns 32 bytes' do + expect(secret).to be_a(String) + expect(secret.length).to eq(32) + expect(secret.encoding).to eq(Encoding::ASCII_8BIT) + end + + it 'accepts a trailing newline' do + File.open(test_class.secret_path, 'a') { |f| f.write "\n" } + + expect(secret.length).to eq(32) + end + + it 'raises an exception if the secret file cannot be read' do + File.delete(test_class.secret_path) + + expect { secret }.to raise_exception(Errno::ENOENT) + end + + it 'raises an exception if the secret file contains the wrong number of bytes' do + File.truncate(test_class.secret_path, 0) + + expect { secret }.to raise_exception(RuntimeError) + end + end + + describe '.write_secret' do + it 'uses mode 0600' do + expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + bytes = Base64.strict_decode64(File.read(test_class.secret_path)) + + expect(bytes).not_to be_empty + end + end + + describe '.decode_jwt_for_issuer' do + let(:payload) { { 'iss' => 'test_issuer' } } + + it 'accepts a correct header' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.not_to raise_error + end + + it 'raises an error when the JWT is not signed' do + encoded_message = JWT.encode(payload, nil, 'none') + + expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error when the header is signed with the wrong secret' do + encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') + + expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error when the issuer is incorrect' do + payload['iss'] = 'somebody else' + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + end + end +end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 98421cd12d3..88bc5034da5 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -200,57 +200,6 @@ describe Gitlab::Workhorse do end end - describe ".secret" do - subject { described_class.secret } - - before do - described_class.instance_variable_set(:@secret, nil) - described_class.write_secret - end - - it 'returns 32 bytes' do - expect(subject).to be_a(String) - expect(subject.length).to eq(32) - expect(subject.encoding).to eq(Encoding::ASCII_8BIT) - end - - it 'accepts a trailing newline' do - File.open(described_class.secret_path, 'a') { |f| f.write "\n" } - expect(subject.length).to eq(32) - end - - it 'raises an exception if the secret file cannot be read' do - File.delete(described_class.secret_path) - expect { subject }.to raise_exception(Errno::ENOENT) - end - - it 'raises an exception if the secret file contains the wrong number of bytes' do - File.truncate(described_class.secret_path, 0) - expect { subject }.to raise_exception(RuntimeError) - end - end - - describe ".write_secret" do - let(:secret_path) { described_class.secret_path } - before do - begin - File.delete(secret_path) - rescue Errno::ENOENT - end - - described_class.write_secret - end - - it 'uses mode 0600' do - expect(File.stat(secret_path).mode & 0777).to eq(0600) - end - - it 'writes base64 data' do - bytes = Base64.strict_decode64(File.read(secret_path)) - expect(bytes).not_to be_empty - end - end - describe '#verify_api_request!' do let(:header_key) { described_class::INTERNAL_API_REQUEST_HEADER } let(:payload) { { 'iss' => 'gitlab-workhorse' } } -- cgit v1.2.1