diff --git a/INSTALL.md b/INSTALL.md index 42db4cbc..140082c9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,4 +1,4 @@ -**Inštalačná príručka popisuje komponent verzie [3.0.2](https://github.com/slovensko-digital/slovensko-sk-api/releases/tag/v3.0.2), uistite sa, že čítate príručku [verzie komponentu](https://github.com/slovensko-digital/slovensko-sk-api/releases), ktorý používate.** +**Inštalačná príručka popisuje komponent verzie [3.0.3](https://github.com/slovensko-digital/slovensko-sk-api/releases/tag/v3.0.3), uistite sa, že čítate príručku [verzie komponentu](https://github.com/slovensko-digital/slovensko-sk-api/releases), ktorý používate.** # slovensko.sk API - Inštalačná príručka diff --git a/app/controllers/administration/certificates_controller.rb b/app/controllers/administration/certificates_controller.rb new file mode 100644 index 00000000..15cc2d13 --- /dev/null +++ b/app/controllers/administration/certificates_controller.rb @@ -0,0 +1,24 @@ +class Administration::CertificatesController < ApiController + before_action { authenticate(allow_plain: true) } + + before_action(only: :create) { head :conflict if UpvsEnvironment.subject?(params[:id]) } + before_action(only: [:show, :destroy]) { head :not_found unless UpvsEnvironment.subject?(params[:id]) } + + def create + UpvsEnvironment.create_subject(params[:id], **params.permit(:cin).to_options) + + head :created + end + + def show + subject = UpvsEnvironment.subject(params[:id]) + + render json: subject + end + + def destroy + UpvsEnvironment.delete_subject(params[:id]) + + head :no_content + end +end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 201e117e..5bf5483e 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -10,7 +10,7 @@ def index status = :ok health = { description: 'slovensko.sk API', - version: '3.0.2', + version: '3.0.3', status: 'pass', checks: { 'environment:variables' => environment_variables, diff --git a/app/controllers/iam/identities_controller.rb b/app/controllers/iam/identities_controller.rb index e0ab8c15..4cd2d743 100644 --- a/app/controllers/iam/identities_controller.rb +++ b/app/controllers/iam/identities_controller.rb @@ -18,7 +18,9 @@ def show def search query = params.permit( :match, :page, :per_page, - :ids, :uris, :en, :email, :phone, + :en, :email, :phone, + ids: [], + uris: [], address: [:type, :country, :district, :municipality, :street, :building_number, :registration_number], corporate_body: [:cin, :tin, :name], natural_person: [:given_name, :family_name, :date_of_birth, :place_of_birth], diff --git a/app/controllers/sktalk_controller.rb b/app/controllers/sktalk_controller.rb index 670d22e0..6d2f0c20 100644 --- a/app/controllers/sktalk_controller.rb +++ b/app/controllers/sktalk_controller.rb @@ -11,6 +11,8 @@ class SktalkController < ApiController rescue_from(SktalkReceiver::ReceiveMessageFormatError) { render_bad_request(:invalid, :message) } rescue_from(SktalkReceiver::ReceiveAsSaveToFolderError) { render_unprocessable_entity(:received_as_being_saved_to_folder) } + PREPARE_FOR_LATER_RECEIVE_SCOPES = %w[sktalk/receive sktalk/receive_and_save_to_outbox sktalk/save_to_outbox] + def receive render json: { receive_result: sktalk_receiver(upvs_identity).receive(params[:message]) } end @@ -26,6 +28,11 @@ def save_to_outbox # allow sending invalid sktalk to cache token for SSO def prepare_for_later_receive(message_builder: SktalkMessageBuilder) message = message_builder.new(class: 'EGOV_APPLICATION', posp_id: 'App.GeneralAgenda', posp_version: '1.9') + sktalk_receiver(upvs_identity).receive(message.to_xml) + + long_lasting_obo_token = Environment.api_token_authenticator.generate_long_lasting_token(authenticity_token, PREPARE_FOR_LATER_RECEIVE_SCOPES) + + render status: :ok, json: {"token": long_lasting_obo_token} end end diff --git a/app/services/api_token_authenticator.rb b/app/services/api_token_authenticator.rb index 231c9336..6e1c5f4a 100644 --- a/app/services/api_token_authenticator.rb +++ b/app/services/api_token_authenticator.rb @@ -75,6 +75,19 @@ def verify_token(token, allow_plain: false, allow_sub: false, allow_obo_token: f [sub, obo] end + def generate_long_lasting_token(token, scopes) + options = { + algorithm: 'RS256', + verify_expiration: false, + verify_not_before: false, + verify_jti: -> (jti) { jti =~ JTI_PATTERN }, + } + + payload, header = JWT.decode(token, @public_key, true, options) + + @obo_token_authenticator.generate_long_lasting_token(payload['obo'], scopes) + end + private def obo_token_support? diff --git a/app/services/obo_token_authenticator.rb b/app/services/obo_token_authenticator.rb index 0cdab6c0..c6022e58 100644 --- a/app/services/obo_token_authenticator.rb +++ b/app/services/obo_token_authenticator.rb @@ -1,6 +1,14 @@ # See https://tools.ietf.org/html/rfc7519 class OboTokenAuthenticator + DECODE_OPTIONS = { + algorithm: 'RS256', + verify_expiration: false, + verify_not_before: false, + verify_iat: false, + verify_jti: true, + } + def initialize(assertion_store:, key_pair:, proxy_subject:) @assertion_store = assertion_store @key_pair = key_pair @@ -26,22 +34,23 @@ def generate_token(response, scopes: []) ass = assertion_to_s(assertion) - loop do - jti = SecureRandom.uuid + payload = { sub: sub, exp: exp, nbf: nbf, iat: iat, name: name, scopes: scopes } - payload = { sub: sub, exp: exp, nbf: nbf, iat: iat, jti: jti, name: name, scopes: scopes } - exp_in = exp - Time.now.to_f + save_to_assertion_store(ass, payload) + end - raise ArgumentError, :exp if exp_in <= 0 + def generate_long_lasting_token(token, scopes) + payload, header = JWT.decode(token, @key_pair.public_key, true, DECODE_OPTIONS) - next unless @assertion_store.write(jti, ass, expires_in: exp_in, unless_exist: true) + # TODO parse STS token expiration from assertion + token_expiration = Time.now.to_i + 120.minutes.to_i - begin - return JWT.encode(payload, @key_pair, 'RS256') - rescue => error - @assertion_store.delete(jti) and raise(error) - end - end + payload['exp'] = token_expiration + payload['scopes'] = scopes + + assertion = @assertion_store.read(payload['jti']) + + save_to_assertion_store(assertion, payload.symbolize_keys) end def invalidate_token(token) @@ -52,15 +61,7 @@ def invalidate_token(token) end def verify_token(token, scope: nil) - options = { - algorithm: 'RS256', - verify_expiration: false, - verify_not_before: false, - verify_iat: false, - verify_jti: true, - } - - payload, header = JWT.decode(token, @key_pair.public_key, true, options) + payload, header = JWT.decode(token, @key_pair.public_key, true, DECODE_OPTIONS) exp, nbf, iat, jti = payload['exp'], payload['nbf'], payload['iat'], payload['jti'] raise JWT::InvalidPayload, :exp unless exp.is_a?(Integer) @@ -88,6 +89,26 @@ def verify_token(token, scope: nil) private + def save_to_assertion_store(assertion, payload) + loop do + jti = SecureRandom.uuid + + payload[:jti] = jti + + exp_in = payload[:exp] - Time.now.to_f + + raise ArgumentError, :exp if exp_in <= 0 + + next unless @assertion_store.write(jti, assertion, expires_in: exp_in, unless_exist: true) + + begin + return JWT.encode(payload, @key_pair, 'RS256') + rescue => error + @assertion_store.delete(jti) and raise(error) + end + end + end + def parse_assertion(response) document = response.decrypted_document || response.document assertion = REXML::XPath.first(document, '//saml:Assertion') diff --git a/public/openapi.yaml b/public/openapi.yaml index 5af50c7a..6cd86cf9 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.0 info: title: slovensko.sk API - version: 3.0.2 (Komunitná verzia) 8.0.2 (Prémium verzia) + version: 3.0.3 (Komunitná verzia) 8.0.3 (Prémium verzia) description: | slovensko.sk API je proxy REST API komponent k službám www.slovensko.sk (Ústredný portál verejnej správy – ÚPVS), pomocou ktorých je možné: @@ -294,10 +294,37 @@ paths: summary: Pripraví odosielanie podania aj po vypršaní WebSSO session description: | Pripraví odosielanie podania aj po vypršaní WebSSO session. - responses: - 204: - description: Úspešné získanie tokenu z modulu IAM. + Vráti OBO (On-Behalf-Of) token použiteľný na odoslanie podaní prihláseného používateľa aj po vypršaní WebSSO session. OBO token je platný 120 minút. + responses: + 200: + description: | + OBO token v JWT formáte, ktorého payload vyzerá nasledovne: + + { + "sub": "rc://sk/8311577984_tisici_janko", + "exp": 1545153549, + "nbf": 1545146349, + "iat": 1545146349, + "jti": "ad8e5d2a-85ff-46b9-a13f-ac860db9acee", + "name": "Janko Tisíci", + "scopes": [ + "sktalk/receive", + "sktalk/receive_and_save_to_outbox", + "sktalk/save_to_outbox" + ] + } + content: + application/json: + schema: + type: object + properties: + token: + schema: + type: string + format: jwt + required: + - token security: - 'API + OBO Token': [] @@ -3295,6 +3322,7 @@ components: edesk_remote_uri: description: URI adresa vzdialenej eDesk schránky. type: string + nullable: true format: uri example: null edesk_cuet_delivery_enabled: diff --git a/security/sso/.keep b/security/sso/.keep new file mode 100644 index 00000000..e69de29b diff --git a/security/sts/.keep b/security/sts/.keep new file mode 100644 index 00000000..e69de29b diff --git a/security/tls/.keep b/security/tls/.keep new file mode 100644 index 00000000..e69de29b diff --git a/spec/requests/administration_spec.rb b/spec/requests/administration_spec.rb new file mode 100644 index 00000000..da3beedb --- /dev/null +++ b/spec/requests/administration_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe 'Administration' do + allow_api_token_with_obo_token! + + let(:token) { api_token } + + describe 'POST /administration/certificates' do + let(:params) do + { + id: 'iscep_00956852_10236', + cin: '00956852_10236', + } + end + + after(:example) { suppress(SystemCallError) { UpvsEnvironment.delete_subject('iscep_00956852_10236') }} + + it 'creates certificate' do + post '/administration/certificates', headers: headers, params: params, as: :json + + expect(response.status).to eq(201) + expect(response.body).to be_empty + + expect(UpvsEnvironment.subject?('iscep_00956852_10236')).to eq(true) + end + + context 'keystore' do + subject { Rails.root.join('security', 'sts', "iscep_00956852_10236_#{Upvs.env}.keystore") } + + before(:example) { allow(Upvs).to receive(:env).and_return(ActiveSupport::StringInquirer.new('prod')) } + before(:example) { stub_const('ENV', ENV.merge('UPVS_KS_SALT' => SecureRandom.hex(20), 'UPVS_PK_SALT' => SecureRandom.hex(20))) } + + it 'has JKS format' do + post '/administration/certificates', headers: headers, params: params, as: :json + + expect(subject.read(4).unpack('H*').first).to eq('feedfeed') + end + + it 'has two passwords' do + ks, pk = ENV.values_at('UPVS_KS_SALT', 'UPVS_PK_SALT').map { |salt| Digest::SHA1.hexdigest("#{salt}:#{params[:id]}") } + + post '/administration/certificates', headers: headers, params: params, as: :json + + expect(ks).not_to eq(pk) + expect(KeyStore.new(subject.to_s, ks).private_key(params[:id], pk)).to be + end + end + + pending 'responds with 400 if request does not contain identifier' + pending 'responds with 400 if request contains malicious identifier' # TODO check against shell escape stuff + + pending 'responds with 400 if request does not contain CIN' + pending 'responds with 400 if request contains malicious CIN' # TODO check against shell escape stuff + + it 'responds with 409 if certificate already exists' do + UpvsEnvironment.create_subject('iscep_00956852_10236', cin: '00956852_10236') + + post '/administration/certificates', headers: headers, params: params, as: :json + + expect(response.status).to eq(409) + expect(response.body).to be_empty + end + + include_examples 'API request media types', post: '/administration/certificates', accept: 'application/json', expect_response_body: false + include_examples 'API request authentication', post: '/administration/certificates', allow_plain: true + end + + describe 'GET /administration/certificates/{id}' do + before(:example) { UpvsEnvironment.create_subject('iscep_00956852_10236', cin: '00956852_10236') } + + after(:example) { suppress(SystemCallError) { UpvsEnvironment.delete_subject('iscep_00956852_10236') }} + + it 'gets certificate' do + get '/administration/certificates/iscep_00956852_10236', headers: headers + + expect(response.status).to eq(200) + expect(response.object.keys).to contain_exactly(:certificate, :fingerprint, :not_after, :subject) + + expect(response.object[:certificate]).to match(/\A-{5}BEGIN CERTIFICATE-{5}\n.+\n-{5}END CERTIFICATE-{5}\n\z/m) + expect(response.object[:fingerprint]).to match(/\A[0-9a-f]{40}\z/) + expect(response.object[:not_after]).to eq(response.object[:not_after].in_time_zone.as_json) + expect(response.object[:subject]).to eq("ico-00956852_10236") + end + + pending 'responds with 400 if request contains malicious identifier' # TODO check against shell escape stuff + + it 'responds with 404 if certificate does not exist' do + UpvsEnvironment.delete_subject('iscep_00956852_10236') + + get '/administration/certificates/iscep_00956852_10236', headers: headers + + expect(response.status).to eq(404) + expect(response.body).to be_empty + end + + include_examples 'API request media types', get: '/administration/certificates/iscep_00956852_10236', accept: 'application/json' + include_examples 'API request authentication', get: '/administration/certificates/iscep_00956852_10236', allow_plain: true + end + + describe 'DELETE /administration/certificates/{id}' do + before(:example) { UpvsEnvironment.create_subject('iscep_00956852_10236', cin: '00956852_10236') } + + after(:example) { suppress(SystemCallError) { UpvsEnvironment.delete_subject('iscep_00956852_10236') }} + + it 'deletes certificate' do + delete '/administration/certificates/iscep_00956852_10236', headers: headers + + expect(response.status).to eq(204) + expect(response.body).to be_empty + + expect(UpvsEnvironment.subject?('iscep_00956852_10236')).to eq(false) + end + + pending 'responds with 400 if request contains malicious identifier' # TODO check against shell escape stuff + + it 'responds with 404 if certificate does not exist' do + UpvsEnvironment.delete_subject('iscep_00956852_10236') + + delete '/administration/certificates/iscep_00956852_10236', headers: headers + + expect(response.status).to eq(404) + expect(response.body).to be_empty + end + + include_examples 'API request media types', delete: '/administration/certificates/iscep_00956852_10236', accept: 'application/json', expect_response_body: false + include_examples 'API request authentication', delete: '/administration/certificates/iscep_00956852_10236', allow_plain: true + end + + describe 'GET /administration/eform/synchronize' do + it 'schedules synchronization' do + expect(DownloadFormTemplatesJob).to receive(:perform_later) + + get '/administration/eform/synchronize', headers: headers + + expect(response.status).to eq(204) + expect(response.body).to be_empty + end + + include_examples 'API request media types', get: '/administration/eform/synchronize', accept: 'application/json', expect_response_body: false + include_examples 'API request authentication', get: '/administration/eform/synchronize', allow_plain: true + end +end diff --git a/spec/requests/api/sktalk_spec.rb b/spec/requests/api/sktalk_spec.rb index 54223022..768767d4 100644 --- a/spec/requests/api/sktalk_spec.rb +++ b/spec/requests/api/sktalk_spec.rb @@ -255,7 +255,9 @@ def set_upvs_expectations end describe 'POST /api/sktalk/prepare_for_later_receive' do - let(:headers) { respond_to?(:token) ? Hash['Authorization' => 'Bearer ' + token] : Hash.new } + before(:example) { travel_to(sso_response_issued_at) } + + let(:headers) { respond_to?(:token) ? Hash['Authorization' => 'Bearer ' + api_token_with_obo_token(scopes: ['sktalk/prepare_for_later_receive'])] : Hash.new } let(:template) do file_fixture('sktalk/egov_application_empty_general_agenda.xml').read @@ -265,12 +267,13 @@ def set_upvs_expectations expect(upvs.sktalk).to receive(:receive).with(sktalk_message_matching(template)).and_return(3100110) end - it 'prepares for later receive' do + it 'returns long lasting OBO token', if: sso_support? do set_upvs_expectations get "/api/sktalk/prepare_for_later_receive", headers: headers - expect(response.status).to eq(204) + expect(response.status).to eq(200) + expect(response.object[:token]).to be end end end diff --git a/spec/requests/health_spec.rb b/spec/requests/health_spec.rb index 63854bae..26a692a6 100644 --- a/spec/requests/health_spec.rb +++ b/spec/requests/health_spec.rb @@ -65,7 +65,7 @@ def expect_fail(checks) expect(response.status).to eq(200) expect(response.object.with_indifferent_access).to match( description: 'slovensko.sk API', - version: '3.0.2', + version: '3.0.3', status: 'pass', checks: hash_including(*checks), links: {