diff --git a/.rubocop.yml b/.rubocop.yml index c69a64de8..295ae2d66 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ AllCops: - "vendor/**/*" - "db/schema.rb" UseCache: false - TargetRubyVersion: 2.2 + TargetRubyVersion: 2.4 ##################### Style #################################### diff --git a/Gemfile b/Gemfile index 881bcefca..d5f785c6f 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,9 @@ gem 'bootstrap_form' gem 'yomu' gem 'recaptcha', require: 'recaptcha/rails' gem 'sanitize', '~> 4.4' + +# Gems for OAuth2 subsystem +gem 'doorkeeper', '~> 4.4' gem 'omniauth' gem 'omniauth-linkedin-oauth2' diff --git a/Gemfile.lock b/Gemfile.lock index c5f2bd7e6..12e8c7223 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -216,6 +216,8 @@ GEM discard (1.0.0) activerecord (>= 4.2, < 6) docile (1.1.5) + doorkeeper (4.4.1) + railties (>= 4.2) erubi (1.7.1) execjs (2.7.0) factory_bot (4.8.2) @@ -563,6 +565,7 @@ DEPENDENCIES devise_invitable devise_security_extension! discard (~> 1.0) + doorkeeper (~> 4.4) factory_bot_rails faker figaro diff --git a/app/controllers/api/api_controller.rb b/app/controllers/api/api_controller.rb index 630e7fa4c..e1c5d76a3 100644 --- a/app/controllers/api/api_controller.rb +++ b/app/controllers/api/api_controller.rb @@ -1,12 +1,12 @@ +# frozen_string_literal: true + module Api class ApiController < ActionController::API attr_reader :iss attr_reader :token attr_reader :current_user - before_action :load_token, except: %i(authenticate status health) - before_action :load_iss, except: %i(authenticate status health) - before_action :authenticate_request!, except: %i(authenticate status health) + before_action :authenticate_request!, except: %i(status health) rescue_from StandardError do |e| logger.error e.message @@ -47,30 +47,8 @@ module Api render json: response, status: :ok end - def authenticate - if auth_params[:grant_type] == 'password' - user = User.find_by_email(auth_params[:email]) - unless user && user.valid_password?(auth_params[:password]) - raise StandardError, 'Default: Wrong user password' - end - payload = { user_id: user.id } - token = CoreJwt.encode(payload) - render json: { token_type: 'bearer', access_token: token } - else - raise StandardError, 'Default: Wrong grant type in request' - end - end - private - def load_token - if request.headers['Authorization'] - @token = - request.headers['Authorization'].scan(/Bearer (.*)$/).flatten.last - end - raise StandardError, 'Common: No token in the header' unless @token - end - def azure_jwt_auth return unless iss =~ %r{windows.net/|microsoftonline.com/} token_payload, = Api::AzureJwt.decode(token) @@ -81,6 +59,12 @@ module Api end def authenticate_request! + @token = request.headers['Authorization']&.sub('Bearer ', '') + raise StandardError, 'Common: No token in the header' unless @token + + @iss = CoreJwt.read_iss(token) + raise JWT::InvalidPayload, 'Common: Missing ISS in the token' unless @iss + Extends::API_PLUGABLE_AUTH_METHODS.each do |auth_method| method(auth_method).call return true if current_user @@ -105,11 +89,6 @@ module Api end end - def load_iss - @iss = CoreJwt.read_iss(token) - raise JWT::InvalidPayload, 'Common: Missing ISS in the token' unless @iss - end - def auth_params params.permit(:grant_type, :email, :password) end diff --git a/app/models/user.rb b/app/models/user.rb index c124181ca..1156e0174 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -198,6 +198,13 @@ class User < ApplicationRecord has_many :zip_exports, inverse_of: :user, dependent: :destroy has_many :datatables_teams, class_name: '::Views::Datatables::DatatablesTeam' + has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', + foreign_key: :resource_owner_id, + dependent: :delete_all + has_many :access_tokens, class_name: 'Doorkeeper::AccessToken', + foreign_key: :resource_owner_id, + dependent: :delete_all + # If other errors besides parameter "avatar" exist, # they will propagate to "avatar" also, so remove them # and put all other (more specific ones) in it diff --git a/app/services/api/core_jwt.rb b/app/services/api/core_jwt.rb index b4f7e9f60..cf872df9b 100644 --- a/app/services/api/core_jwt.rb +++ b/app/services/api/core_jwt.rb @@ -30,5 +30,13 @@ module Api return true if time_left < (Api.configuration.core_api_token_ttl.to_i / 2) false end + + # Method used by Doorkeeper for custom tokens + def self.generate(options = {}) + encode( + { user_id: options[:resource_owner_id] }, + options[:expires_in].seconds.from_now.to_i + ) + end end end diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 000000000..5b2f8284d --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,9 @@ +
+ + +
+ <%= @pre_auth.error_response.body[:error_description] %> +
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 000000000..b37bd506d --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,42 @@ +
+ + +
+

+ <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %> +

+ + <% if @pre_auth.scopes.count > 0 %> +
+

<%= t('.able_to') %>:

+ +
    + <% @pre_auth.scopes.each do |scope| %> +
  • <%= t scope, scope: [:doorkeeper, :scopes] %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success btn-lg btn-block" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger btn-lg btn-block" %> + <% end %> +
+
+
diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 000000000..e6e37e3b2 --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,9 @@ +
+ + +
+ <%= params[:code] %> +
+
diff --git a/config/application.rb b/config/application.rb index 901fb0806..d76340c7b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,5 +42,11 @@ module Scinote # SciNote Core Application version VERSION = File.read(Rails.root.join('VERSION')).strip.freeze + + # Doorkeeper overrides + config.to_prepare do + # Only Authorization endpoint + Doorkeeper::AuthorizationsController.layout 'sign_in_halt' + end end end diff --git a/config/initializers/api.rb b/config/initializers/api.rb index 4ab3e3898..d65a540b9 100644 --- a/config/initializers/api.rb +++ b/config/initializers/api.rb @@ -3,7 +3,7 @@ Api.configure do |config| config.core_api_sign_alg = ENV['CORE_API_SIGN_ALG'] end if ENV['CORE_API_TOKEN_TTL'] - config.core_api_token_ttl = ENV['CORE_API_TOKEN_TTL'] + config.core_api_token_ttl = ENV['CORE_API_TOKEN_TTL'].to_i.seconds end if ENV['CORE_API_TOKEN_ISS'] config.core_api_token_iss = ENV['CORE_API_TOKEN_ISS'] diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 000000000..9153fbabd --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,136 @@ +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (needs plugins) + orm :active_record + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + current_user || warden.authenticate!(scope: :user) + end + + # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. + # admin_authenticator do + # # Put your admin authentication logic here. + # # Example implementation: + # Admin.find_by_id(session[:admin_id]) || redirect_to(new_admin_session_url) + # end + + # Authorization Code expiration time (default 10 minutes). + authorization_code_expires_in 10.minutes + + # Access token expiration time (default 2 hours). + # If you want to disable expiration, set this to nil. + access_token_expires_in 2.hours + + # Assign a custom TTL for implicit grants. + # custom_access_token_expires_in do |oauth_client| + # oauth_client.application.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator + access_token_generator 'Api::CoreJwt' + + # The controller Doorkeeper::ApplicationController inherits from. + # Defaults to ActionController::Base. + # https://github.com/doorkeeper-gem/doorkeeper#custom-base-controller + # base_controller 'DoorkeeperCustomController' + + # Reuse access token for the same resource owner within an application (disabled by default) + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # reuse_access_token + + # Issue access tokens with refresh token (disabled by default) + use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter confirmation: true (default false) if you want to enforce ownership of + # a registered application + # Note: you must also run the rails g doorkeeper:application_owner generator to provide the necessary support + # enable_application_owner confirmation: false + + # Define access token scopes for your provider + # For more information go to + # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes + # default_scopes :public + # optional_scopes :write, :update + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated + # for more information on customization + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Change the native redirect uri for client apps + # When clients register with the following redirect uri, they won't be redirected to any server and the authorization code will be displayed within the provider + # The value can be any string. Use nil to disable this feature. When disabled, clients must provide a valid URL + # (Similar behaviour: https://developers.google.com/accounts/docs/OAuth2InstalledApp#choosingredirecturi) + # + # native_redirect_uri 'urn:ietf:wg:oauth:2.0:oob' + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # Callable objects such as proc, lambda, block or any object that responds to + # #call can be used in order to allow conditional checks (to allow non-SSL + # redirects to localhost for example). + # + # force_ssl_in_redirect_uri !Rails.env.development? + # + # force_ssl_in_redirect_uri { |uri| uri.host != 'localhost' } + + # Specify what redirect URI's you want to block during creation. Any redirect + # URI is whitelisted by default. + # + # You can use this option in order to forbid URI's with 'javascript' scheme + # for example. + # + # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' } + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # http://tools.ietf.org/html/rfc6819#section-4.4.2 + # http://tools.ietf.org/html/rfc6819#section-4.4.3 + # + grant_flows %w(authorization_code) + + # Hook into the strategies' request & response life-cycle in case your + # application needs advanced customization or logging: + # + # before_successful_strategy_response do |request| + # puts "BEFORE HOOK FIRED! #{request}" + # end + # + # after_successful_strategy_response do |request, response| + # puts "AFTER HOOK FIRED! #{request}, #{response}" + # end + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # WWW-Authenticate Realm (default "Doorkeeper"). + # realm "Doorkeeper" +end diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 000000000..e475b3712 --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,128 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URI' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + forbidden_uri: 'is forbidden by the server.' + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + edit: 'Edit' + destroy: 'Destroy' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.' + redirect_uri: 'Use one line per URI' + native_redirect_uri: 'Use %{native_redirect_uri} if you want to add localhost URIs for development purposes' + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Your applications' + new: 'New Application' + name: 'Name' + callback_url: 'Callback URL' + confidential: 'Confidential?' + confidentiality: + 'yes': 'Yes' + 'no': 'No' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'Application Id' + secret: 'Secret' + scopes: 'Scopes' + confidential: 'Confidential' + callback_urls: 'Callback urls' + actions: 'Actions' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'An error has occurred' + new: + title: 'Authorization required' + prompt: 'Authorize %{client_name} to use your account?' + able_to: 'This application will be able to' + show: + title: 'Authorization code' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Your authorized applications' + application: 'Application' + created_at: 'Created At' + date_format: '%Y-%m-%d %H:%M:%S' + + errors: + messages: + # Common error messages + invalid_request: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI." + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + # Configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + nav: + oauth2_provider: 'OAuth2 Provider' + applications: 'Applications' + home: 'Home' + application: + title: 'OAuth authorization required' diff --git a/config/routes.rb b/config/routes.rb index ddca6879a..624ce5778 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,7 @@ Rails.application.routes.draw do + use_doorkeeper do + skip_controllers :applications, :authorized_applications, :token_info + end require 'subdomain' def draw(routes_name) @@ -539,7 +542,6 @@ Rails.application.routes.draw do namespace :api, defaults: { format: 'json' } do get 'health', to: 'api#health' get 'status', to: 'api#status' - post 'auth/token', to: 'api#authenticate' namespace :v1 do resources :teams, only: %i(index show) do end diff --git a/db/migrate/20180813120338_create_doorkeeper_tables.rb b/db/migrate/20180813120338_create_doorkeeper_tables.rb new file mode 100644 index 000000000..d52ebe5fb --- /dev/null +++ b/db/migrate/20180813120338_create_doorkeeper_tables.rb @@ -0,0 +1,71 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration[5.1] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.boolean :confidential, null: false, default: true + t.timestamps null: false + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.integer :resource_owner_id, null: false + t.references :application, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.datetime :created_at, null: false + t.datetime :revoked_at + t.string :scopes + end + + add_index :oauth_access_grants, :token, unique: true + add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id + add_foreign_key( + :oauth_access_grants, + :oauth_applications, + column: :application_id + ) + + create_table :oauth_access_tokens do |t| + t.integer :resource_owner_id + t.references :application + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + t.text :token, null: false + # t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.datetime :revoked_at + t.datetime :created_at, null: false + t.string :scopes + + # If there is a previous_refresh_token column, + # refresh tokens will be revoked after a related access token is used. + # If there is no previous_refresh_token column, + # previous tokens are revoked as soon as a new access token is created. + # Comment out this line if you'd rather have refresh tokens + # instantly revoked. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :oauth_access_tokens, :token, unique: true + add_index :oauth_access_tokens, :resource_owner_id + add_index :oauth_access_tokens, :refresh_token, unique: true + add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + end +end diff --git a/db/schema.rb b/db/schema.rb index ce0aa9260..345e78dde 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180524091143) do +ActiveRecord::Schema.define(version: 20180813120338) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -240,6 +240,47 @@ ActiveRecord::Schema.define(version: 20180524091143) do t.index ["created_at"], name: "index_notifications_on_created_at" end + create_table "oauth_access_grants", force: :cascade do |t| + t.integer "resource_owner_id", null: false + t.bigint "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "scopes" + t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" + t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true + end + + create_table "oauth_access_tokens", force: :cascade do |t| + t.integer "resource_owner_id" + t.bigint "application_id" + t.text "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.string "scopes" + t.string "previous_refresh_token", default: "", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "projects", id: :serial, force: :cascade do |t| t.string "name", null: false t.integer "visibility", default: 0, null: false @@ -864,6 +905,10 @@ ActiveRecord::Schema.define(version: 20180524091143) do add_foreign_key "my_modules", "users", column: "last_modified_by_id" add_foreign_key "my_modules", "users", column: "restored_by_id" add_foreign_key "notifications", "users", column: "generator_user_id" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "projects", "teams" add_foreign_key "projects", "users", column: "archived_by_id" add_foreign_key "projects", "users", column: "created_by_id" diff --git a/docker-compose.yml b/docker-compose.yml index 6d89be949..12186ebb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '2' services: db: container_name: scinote_db_development - image: postgres:9.4 + image: postgres:9.6 volumes: - scinote_development_postgres:/var/lib/postgresql/data