From 3c33724e2e182436a2d8b44ef71d0bdac37c585b Mon Sep 17 00:00:00 2001 From: Vladimir Shushlin Date: Thu, 16 May 2019 09:32:25 +0000 Subject: Add Let's Encrypt client Part of adding Let's Encrypt certificates for pages domains Add acme-client gem Client is being initialized by private key stored in secrets.yml Let's Encrypt account is being created lazily. If it's already created, Acme::Client just gets account_kid by calling new_account method Make Let's Encrypt client an instance Wrap order and challenge classes --- Gemfile | 2 + Gemfile.lock | 3 + .../admin/application_settings_controller.rb | 7 ++ .../admin/application_settings/_pages.html.haml | 5 +- config/routes/admin.rb | 1 + lib/gitlab/lets_encrypt/challenge.rb | 17 +++ lib/gitlab/lets_encrypt/client.rb | 74 +++++++++++++ lib/gitlab/lets_encrypt/order.rb | 23 ++++ locale/gitlab.pot | 2 +- spec/lib/gitlab/lets_encrypt/challenge_spec.rb | 29 +++++ spec/lib/gitlab/lets_encrypt/client_spec.rb | 120 +++++++++++++++++++++ spec/lib/gitlab/lets_encrypt/order_spec.rb | 39 +++++++ spec/support/helpers/lets_encrypt_helpers.rb | 19 ++++ 13 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 lib/gitlab/lets_encrypt/challenge.rb create mode 100644 lib/gitlab/lets_encrypt/client.rb create mode 100644 lib/gitlab/lets_encrypt/order.rb create mode 100644 spec/lib/gitlab/lets_encrypt/challenge_spec.rb create mode 100644 spec/lib/gitlab/lets_encrypt/client_spec.rb create mode 100644 spec/lib/gitlab/lets_encrypt/order_spec.rb create mode 100644 spec/support/helpers/lets_encrypt_helpers.rb diff --git a/Gemfile b/Gemfile index 19432758b34..d20dbe7c2fd 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,8 @@ gem 'u2f', '~> 0.2.1' # GitLab Pages gem 'validates_hostname', '~> 1.0.6' gem 'rubyzip', '~> 1.2.2', require: 'zip' +# GitLab Pages letsencrypt support +gem 'acme-client', '~> 2.0.2' # Browser detection gem 'browser', '~> 2.5' diff --git a/Gemfile.lock b/Gemfile.lock index 1bd88b65124..482352e59d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,6 +4,8 @@ GEM RedCloth (4.3.2) abstract_type (0.0.7) ace-rails-ap (4.1.2) + acme-client (2.0.2) + faraday (~> 0.9, >= 0.9.1) actioncable (5.1.7) actionpack (= 5.1.7) nio4r (~> 2.0) @@ -998,6 +1000,7 @@ PLATFORMS DEPENDENCIES RedCloth (~> 4.3.2) ace-rails-ap (~> 4.1.0) + acme-client (~> 2.0.2) activerecord_sane_schema_dumper (= 1.0) acts-as-taggable-on (~> 6.0) addressable (~> 2.5.2) diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index d445be0eb19..d5bc723aa8c 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -89,6 +89,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ) end + # Getting ToS url requires `directory` api call to Let's Encrypt + # which could result in 500 error/slow rendering on settings page + # Because of that we use separate controller action + def lets_encrypt_terms_of_service + redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url + end + private def set_application_setting diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index 64e01fa2d00..77795dbf913 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -30,8 +30,7 @@ .form-check = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input' = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do - // Terms of Service should actually be a link, but the best way to get the url is using API - // So it will be done in later MR - = _("I have read and agree to the Let's Encrypt Terms of Service") + - terms_of_service_link_start = ''.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path } + = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: ''.html_safe } = f.submit _('Save changes'), class: "btn btn-success" diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 90d7f4a04d4..bc19219a0b8 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -111,6 +111,7 @@ namespace :admin do put :reset_health_check_token put :clear_repository_check_states get :integrations, :repository, :templates, :ci_cd, :reporting, :metrics_and_profiling, :network, :geo, :preferences + get :lets_encrypt_terms_of_service end resources :labels diff --git a/lib/gitlab/lets_encrypt/challenge.rb b/lib/gitlab/lets_encrypt/challenge.rb new file mode 100644 index 00000000000..6a7f5e965c5 --- /dev/null +++ b/lib/gitlab/lets_encrypt/challenge.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Gitlab + module LetsEncrypt + class Challenge + def initialize(acme_challenge) + @acme_challenge = acme_challenge + end + + delegate :url, :token, :file_content, :status, :request_validation, to: :acme_challenge + + private + + attr_reader :acme_challenge + end + end +end diff --git a/lib/gitlab/lets_encrypt/client.rb b/lib/gitlab/lets_encrypt/client.rb new file mode 100644 index 00000000000..d7468b06767 --- /dev/null +++ b/lib/gitlab/lets_encrypt/client.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Gitlab + module LetsEncrypt + class Client + PRODUCTION_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory' + STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory' + + def new_order(domain_name) + ensure_account + + acme_order = acme_client.new_order(identifiers: [domain_name]) + + ::Gitlab::LetsEncrypt::Order.new(acme_order) + end + + def load_order(url) + ensure_account + + # rubocop: disable CodeReuse/ActiveRecord + ::Gitlab::LetsEncrypt::Order.new(acme_client.order(url: url)) + # rubocop: enable CodeReuse/ActiveRecord + end + + def load_challenge(url) + ensure_account + + ::Gitlab::LetsEncrypt::Challenge.new(acme_client.challenge(url: url)) + end + + def terms_of_service_url + acme_client.terms_of_service + end + + def enabled? + return false unless Feature.enabled?(:pages_auto_ssl) + + Gitlab::CurrentSettings.lets_encrypt_terms_of_service_accepted + end + + private + + def acme_client + @acme_client ||= ::Acme::Client.new(private_key: private_key, directory: acme_api_directory_url) + end + + def private_key + @private_key ||= OpenSSL::PKey.read(Gitlab::Application.secrets.lets_encrypt_private_key) + end + + def admin_email + Gitlab::CurrentSettings.lets_encrypt_notification_email + end + + def contact + "mailto:#{admin_email}" + end + + def ensure_account + raise 'Acme integration is disabled' unless enabled? + + @acme_account ||= acme_client.new_account(contact: contact, terms_of_service_agreed: true) + end + + def acme_api_directory_url + if Rails.env.production? + PRODUCTION_DIRECTORY_URL + else + STAGING_DIRECTORY_URL + end + end + end + end +end diff --git a/lib/gitlab/lets_encrypt/order.rb b/lib/gitlab/lets_encrypt/order.rb new file mode 100644 index 00000000000..5109b5e9843 --- /dev/null +++ b/lib/gitlab/lets_encrypt/order.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module LetsEncrypt + class Order + def initialize(acme_order) + @acme_order = acme_order + end + + def new_challenge + authorization = @acme_order.authorizations.first + challenge = authorization.http + ::Gitlab::LetsEncrypt::Challenge.new(challenge) + end + + delegate :url, :status, to: :acme_order + + private + + attr_reader :acme_order + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 078a398045d..68397c38700 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4905,7 +4905,7 @@ msgstr "" msgid "I accept the|Terms of Service and Privacy Policy" msgstr "" -msgid "I have read and agree to the Let's Encrypt Terms of Service" +msgid "I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}" msgstr "" msgid "ID" diff --git a/spec/lib/gitlab/lets_encrypt/challenge_spec.rb b/spec/lib/gitlab/lets_encrypt/challenge_spec.rb new file mode 100644 index 00000000000..74622f356de --- /dev/null +++ b/spec/lib/gitlab/lets_encrypt/challenge_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::LetsEncrypt::Challenge do + delegated_methods = { + url: 'https://example.com/', + status: 'pending', + token: 'tokenvalue', + file_content: 'hereisfilecontent', + request_validation: true + } + + let(:acme_challenge) do + acme_challenge = instance_double('Acme::Client::Resources::Challenge') + allow(acme_challenge).to receive_messages(delegated_methods) + acme_challenge + end + + let(:challenge) { described_class.new(acme_challenge) } + + delegated_methods.each do |method, value| + describe "##{method}" do + it 'delegates to Acme::Client::Resources::Challenge' do + expect(challenge.public_send(method)).to eq(value) + end + end + end +end diff --git a/spec/lib/gitlab/lets_encrypt/client_spec.rb b/spec/lib/gitlab/lets_encrypt/client_spec.rb new file mode 100644 index 00000000000..16a16acfd25 --- /dev/null +++ b/spec/lib/gitlab/lets_encrypt/client_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::LetsEncrypt::Client do + include LetsEncryptHelpers + + let(:client) { described_class.new } + + before do + stub_application_setting( + lets_encrypt_notification_email: 'myemail@test.example.com', + lets_encrypt_terms_of_service_accepted: true + ) + end + + let!(:stub_client) { stub_lets_encrypt_client } + + shared_examples 'ensures account registration' do + it 'ensures account registration' do + subject + + expect(stub_client).to have_received(:new_account).with( + contact: 'mailto:myemail@test.example.com', + terms_of_service_agreed: true + ) + end + + context 'when acme integration is disabled' do + before do + stub_application_setting(lets_encrypt_terms_of_service_accepted: false) + end + + it 'raises error' do + expect do + subject + end.to raise_error('Acme integration is disabled') + end + end + end + + describe '#new_order' do + subject(:new_order) { client.new_order('example.com') } + + before do + order_double = instance_double('Acme::Order') + allow(stub_client).to receive(:new_order).and_return(order_double) + end + + include_examples 'ensures account registration' + + it 'returns order' do + is_expected.to be_a(::Gitlab::LetsEncrypt::Order) + end + end + + describe '#load_order' do + let(:url) { 'https://example.com/order' } + subject { client.load_order(url) } + + before do + acme_order = instance_double('Acme::Client::Resources::Order') + allow(stub_client).to receive(:order).with(url: url).and_return(acme_order) + end + + include_examples 'ensures account registration' + + it 'loads order' do + is_expected.to be_a(::Gitlab::LetsEncrypt::Order) + end + end + + describe '#load_challenge' do + let(:url) { 'https://example.com/challenge' } + subject { client.load_challenge(url) } + + before do + acme_challenge = instance_double('Acme::Client::Resources::Challenge') + allow(stub_client).to receive(:challenge).with(url: url).and_return(acme_challenge) + end + + include_examples 'ensures account registration' + + it 'loads challenge' do + is_expected.to be_a(::Gitlab::LetsEncrypt::Challenge) + end + end + + describe '#enabled?' do + subject { client.enabled? } + + context 'when terms of service are accepted' do + it { is_expected.to eq(true) } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(pages_auto_ssl: false) + end + + it { is_expected.to eq(false) } + end + end + + context 'when terms of service are not accepted' do + before do + stub_application_setting(lets_encrypt_terms_of_service_accepted: false) + end + + it { is_expected.to eq(false) } + end + end + + describe '#terms_of_service_url' do + subject { client.terms_of_service_url } + + it 'returns valid url' do + is_expected.to eq("https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf") + end + end +end diff --git a/spec/lib/gitlab/lets_encrypt/order_spec.rb b/spec/lib/gitlab/lets_encrypt/order_spec.rb new file mode 100644 index 00000000000..ee7058baf8d --- /dev/null +++ b/spec/lib/gitlab/lets_encrypt/order_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::LetsEncrypt::Order do + delegated_methods = { + url: 'https://example.com/', + status: 'valid' + } + + let(:acme_order) do + acme_order = instance_double('Acme::Client::Resources::Order') + allow(acme_order).to receive_messages(delegated_methods) + acme_order + end + + let(:order) { described_class.new(acme_order) } + + delegated_methods.each do |method, value| + describe "##{method}" do + it 'delegates to Acme::Client::Resources::Order' do + expect(order.public_send(method)).to eq(value) + end + end + end + + describe '#new_challenge' do + before do + challenge = instance_double('Acme::Client::Resources::Challenges::HTTP01') + authorization = instance_double('Acme::Client::Resources::Authorization') + allow(authorization).to receive(:http).and_return(challenge) + allow(acme_order).to receive(:authorizations).and_return([authorization]) + end + + it 'returns challenge' do + expect(order.new_challenge).to be_a(::Gitlab::LetsEncrypt::Challenge) + end + end +end diff --git a/spec/support/helpers/lets_encrypt_helpers.rb b/spec/support/helpers/lets_encrypt_helpers.rb new file mode 100644 index 00000000000..7f0886b451c --- /dev/null +++ b/spec/support/helpers/lets_encrypt_helpers.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module LetsEncryptHelpers + def stub_lets_encrypt_client + client = instance_double('Acme::Client') + + allow(client).to receive(:new_account) + allow(client).to receive(:terms_of_service).and_return( + "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf" + ) + + allow(Acme::Client).to receive(:new).with( + private_key: kind_of(OpenSSL::PKey::RSA), + directory: ::Gitlab::LetsEncrypt::Client::STAGING_DIRECTORY_URL + ).and_return(client) + + client + end +end -- cgit v1.2.1