diff --git a/Gemfile b/Gemfile index 59fc6ba5a..307af7e9b 100644 --- a/Gemfile +++ b/Gemfile @@ -28,6 +28,7 @@ end gem 'autoprefixer-rails' +gem 'doorkeeper' gem 'oauth2' gem 'omniauth' diff --git a/Gemfile.lock b/Gemfile.lock index 66593bad4..81962222b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,8 @@ GEM docile (1.1.5) domain_name (0.5.20160826) unf (>= 0.0.5, < 1.0.0) + doorkeeper (4.2.0) + railties (>= 4.2) eco (1.0.0) coffee-script eco-source @@ -366,6 +368,7 @@ DEPENDENCIES daemons delayed_job_active_record diffy + doorkeeper eco em-websocket email_verifier diff --git a/app/assets/javascripts/app/controllers/_application_controller_table.coffee b/app/assets/javascripts/app/controllers/_application_controller_table.coffee index c35ae9507..60c1511d0 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_table.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_table.coffee @@ -78,14 +78,15 @@ class App.ControllerTable extends App.Controller e.preventDefault() console.log('checkboxClick', e.target) - callbackHeader = (header) -> - console.log('current header is', header) + callbackHeader = (headers) -> + console.log('current header is', headers) # add new header item attribute = name: 'some name' display: 'Some Name' - header.push attribute - console.log('new header is', header) + headers.push attribute + console.log('new header is', headers) + headers callbackAttributes = (value, object, attribute, header, refObject) -> console.log('data of item col', value, object, attribute, header, refObject) diff --git a/app/assets/javascripts/app/controllers/api.coffee b/app/assets/javascripts/app/controllers/api.coffee index 9b301bbf5..89fdbd0a0 100644 --- a/app/assets/javascripts/app/controllers/api.coffee +++ b/app/assets/javascripts/app/controllers/api.coffee @@ -34,20 +34,47 @@ class Index extends App.ControllerSubContent App.Setting.unsubscribe(@subscribeApplicationId) table = => + + callbackHeader = (headers) -> + attribute = + name: 'view' + display: 'View' + headers.splice(3, 0, attribute) + attribute = + name: 'token' + display: 'Token' + headers.splice(4, 0, attribute) + headers + + callbackViewAttributes = (value, object, attribute, header, refObject) -> + value = 'X' + value + + callbackTokenAttributes = (value, object, attribute, header, refObject) -> + value = 'X' + value + new App.ControllerTable( - el: @$('.js-appList') - model: App.Application - tableId: 'applications' - objects: App.Application.all() + el: @$('.js-appList') + model: App.Application + tableId: 'applications' + objects: App.Application.all() bindRow: events: 'click': @appEdit + bindCol: + view: + events: + 'click': @appView + token: + events: + 'click': @appToken + callbackHeader: [callbackHeader] + callbackAttributes: + view: [callbackViewAttributes] + token: [callbackTokenAttributes] ) table() - #App.Application.fetchFull( - # table - # clear: true - #) @subscribeApplicationId = App.Application.subscribe(table, initFetch: true, clear: true) @@ -82,6 +109,18 @@ class Index extends App.ControllerSubContent value = @PasswordAccess.prop('checked') App.Setting.set('api_password_access', value) + appToken: (id, e) -> + e.preventDefault() + new ViewAppTokenModal( + app: App.Application.find(id) + ) + + appView: (id, e) -> + e.preventDefault() + new ViewAppModal( + app: App.Application.find(id) + ) + appNew: (e) -> e.preventDefault() new App.ControllerGenericNew( @@ -107,4 +146,51 @@ class Index extends App.ControllerSubContent container: @el.closest('.content') ) +class ViewAppModal extends App.ControllerModal + headPrefix: 'App' + buttonSubmit: false + buttonCancel: true + shown: true + small: true + events: + 'click .js-select': 'selectAll' + + constructor: (params) -> + @head = params.app.name + super + + content: -> + "AppID: +
+ Secret: " + +class ViewAppTokenModal extends App.ControllerModal + headPrefix: 'Generate Token' + buttonSubmit: 'Generate Token' + buttonCancel: true + shown: true + small: true + events: + 'click .js-select': 'selectAll' + + constructor: (params) -> + @head = params.app.name + super + + content: -> + "#{App.i18n.translateContent('Generate Access Token for |%s|', App.Session.get().displayNameLong())}" + + onSubmit: => + @ajax( + id: 'application_token' + type: 'POST' + url: "#{@apiPath}/applications/token" + processData: true + data: JSON.stringify(id: @app.id) + success: (data, status, xhr) => + @contentInline = "#{App.i18n.translateContent('New Access Token is')}: " + @update() + @$('.js-submit').remove() + ) + App.Config.set('API', { prio: 1200, name: 'API', parent: '#system', target: '#system/api', controller: Index, permission: ['admin.api'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/models/application.coffee b/app/assets/javascripts/app/models/application.coffee index 85fd37030..e31faa35e 100644 --- a/app/assets/javascripts/app/models/application.coffee +++ b/app/assets/javascripts/app/models/application.coffee @@ -1,17 +1,16 @@ class App.Application extends App.Model - @configure 'Application', 'name', 'scopes', 'redirect_uri' + @configure 'Application', 'name', 'redirect_uri' @extend Spine.Model.Ajax @url: @apiPath + '/applications' @configure_attributes = [ { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, null: false }, - { name: 'redirect_uri', display: 'Redirect URI', tag: 'textarea', limit: 250, null: false, note: 'Use one line per URI' }, - { name: 'scopes', display: 'Scopes', tag: 'input', note: 'Scopes define the access for' }, - { name: 'clients', display: 'Clients', tag: 'input', readonly: 1 }, + { name: 'redirect_uri', display: 'Callback URL', tag: 'textarea', limit: 250, null: false, note: 'Use one line per URI' }, + { name: 'clients', display: 'Clients', tag: 'input', readonly: 1 }, { name: 'created_at', display: 'Created', tag: 'datetime', readonly: 1 }, { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 }, ] @configure_overview = [ - 'name', 'scopes', 'clients' + 'name', 'redirect_uri', 'clients' ] @configure_delete = true diff --git a/app/assets/javascripts/app/views/api.jst.eco b/app/assets/javascripts/app/views/api.jst.eco index ddffb1fd9..de29d157c 100644 --- a/app/assets/javascripts/app/views/api.jst.eco +++ b/app/assets/javascripts/app/views/api.jst.eco @@ -51,6 +51,7 @@ curl -u <%= @S('email') %>:some_password <%= @C('http_type') %>://<%= @C('fqdn') +

@@ -59,7 +60,7 @@ OAuth URLs are: - @@ -69,9 +70,6 @@ OAuth URLs are: -
<%- @T('Title') %> + <%- @T('Action') %> <%- @T('URL') %>
<%- @T('Getting an Access Token') %> <%= @C('http_type') %>://<%= @C('fqdn') %>/oauth/token -
<%- @T('Revoking Access') %> - <%= @C('http_type') %>://<%= @C('fqdn') %>/oauth/applications
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b732d50aa..d6c2a8451 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -301,7 +301,6 @@ class ApplicationController < ActionController::Base return authentication_check_prerequesits(user, 'token_auth', auth_param) if user end -=begin # check oauth2 token based authentication token = Doorkeeper::OAuth::Token.from_bearer_authorization(request) if token @@ -309,19 +308,23 @@ class ApplicationController < ActionController::Base logger.debug "oauth2 token auth check '#{token}'" access_token = Doorkeeper::AccessToken.by_token(token) + if !access_token + raise Exceptions::NotAuthorized, 'Invalid token!' + end + # check expire if access_token.expires_in && (access_token.created_at + access_token.expires_in) < Time.zone.now raise Exceptions::NotAuthorized, 'OAuth2 token is expired!' end - if access_token.scopes.empty? - raise Exceptions::NotAuthorized, 'OAuth2 scope missing for token!' - end + # if access_token.scopes.empty? + # raise Exceptions::NotAuthorized, 'OAuth2 scope missing for token!' + # end user = User.find(access_token.resource_owner_id) return authentication_check_prerequesits(user, 'token_auth', auth_param) if user end -=end + false end diff --git a/app/controllers/applications_controller.rb b/app/controllers/applications_controller.rb new file mode 100644 index 000000000..1b44c54c2 --- /dev/null +++ b/app/controllers/applications_controller.rb @@ -0,0 +1,72 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class ApplicationsController < ApplicationController + before_action { authentication_check(permission: 'admin.api') } + + def index + all = Doorkeeper::Application.all + if params[:full] + assets = {} + item_ids = [] + all.each { |item| + item_ids.push item.id + if !assets[:Application] + assets[:Application] = {} + end + application = item.attributes + application[:clients] = Doorkeeper::AccessToken.where(application_id: item.id).count + assets[:Application][item.id] = application + } + render json: { + record_ids: item_ids, + assets: assets, + }, status: :ok + return + end + + render json: all, status: :ok + end + + def token + access_token = Doorkeeper::AccessToken.create!(application_id: params[:id], resource_owner_id: current_user.id) + render json: { token: access_token.token }, status: :ok + end + + def show + application = Doorkeeper::Application.find(params[:id]) + render json: application, status: :ok + end + + def create + application = Doorkeeper::Application.new(clean_params) + application.save! + render json: application, status: :ok + end + + def update + application = Doorkeeper::Application.find(params[:id]) + application.update_attributes!(clean_params) + render json: application, status: :ok + end + + def destroy + application = Doorkeeper::Application.find(params[:id]) + application.destroy! + render json: {}, status: :ok + end + + private + + def clean_params + params_data = params.permit! #.to_h + params_data.delete('application') + params_data.delete('action') + params_data.delete('controller') + params_data.delete('id') + params_data.delete('uid') + params_data.delete('secret') + params_data.delete('created_at') + params_data.delete('updated_at') + params_data + end +end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 000000000..4eeb378e4 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,112 @@ +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 + # fail "Please configure doorkeeper resource_owner_authenticator block located in #{__FILE__}" + # Put your resource owner authentication logic here. + # Example implementation: + User.find_by_id(session[:user_id]) || redirect_to(new_user_session_url) + 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 '::Doorkeeper::JWT' + + # The controller Doorkeeper::ApplicationController inherits from. + # Defaults to ActionController::Base. + # https://github.com/doorkeeper-gem/doorkeeper#custom-base-controller + # base_controller 'ApplicationController' + + # 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 the wiki 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 the wiki 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. + # + # force_ssl_in_redirect_uri !Rails.env.development? + + # 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 client_credentials) + + # 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..e67b778be --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,124 @@ +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.' + + 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: + redirect_uri: 'Use one line per URI' + native_redirect_uri: 'Use %{native_redirect_uri} for local tests' + 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' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'Application Id' + secret: 'Secret' + scopes: 'Scopes' + 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 redirect uri included is not valid.' + 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 unconfiged.' + + # 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.' + + # Password Access token errors + invalid_resource_owner: 'The provided resource owner credentials are not valid, or resource owner cannot be found' + + 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/applications.rb b/config/routes/applications.rb new file mode 100644 index 000000000..752e170ea --- /dev/null +++ b/config/routes/applications.rb @@ -0,0 +1,14 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/applications', to: 'applications#index', via: :get + match api_path + '/applications/:id', to: 'applications#show', via: :get + match api_path + '/applications', to: 'applications#create', via: :post + match api_path + '/applications/:id', to: 'applications#update', via: :put + match api_path + '/applications/token', to: 'applications#token', via: :post + + # oauth2 provider routes + use_doorkeeper do + skip_controllers :applications, :authorized_applications + end +end diff --git a/db/migrate/20161101131409_create_doorkeeper_tables.rb b/db/migrate/20161101131409_create_doorkeeper_tables.rb new file mode 100644 index 000000000..da7c84353 --- /dev/null +++ b/db/migrate/20161101131409_create_doorkeeper_tables.rb @@ -0,0 +1,68 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration + 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.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, + :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, + :oauth_applications, + column: :application_id + ) + end +end