diff --git a/app/assets/javascripts/app/views/profile/password.jst.eco b/app/assets/javascripts/app/views/profile/password.jst.eco
index 7d5453354..ba6016bd2 100644
--- a/app/assets/javascripts/app/views/profile/password.jst.eco
+++ b/app/assets/javascripts/app/views/profile/password.jst.eco
@@ -1,11 +1,11 @@
\ No newline at end of file
diff --git a/app/assets/javascripts/app/views/profile/token_access.jst.eco b/app/assets/javascripts/app/views/profile/token_access.jst.eco
new file mode 100644
index 000000000..207b4bfb0
--- /dev/null
+++ b/app/assets/javascripts/app/views/profile/token_access.jst.eco
@@ -0,0 +1,57 @@
+
+
+
<%- @T('You can generate a personal access token for each application you use that needs access to the Zammad API.') %>
+
+
<%- @T('Add a Personal Access Token') %>
+
+
<%- @T('Pick a name for the application, and we\'ll give you a unique token.') %>
+
+
+
+
+
<%- @T('Personal Access Tokens') %>
+
+
+
+ <%- @T('Name') %> |
+ <%- @T('Created') %> |
+
+ <%- @T('Delete') %> |
+
+
+
+ <% if _.isEmpty(@tokens): %>
+
+ <%- @T('none') %>
+ <% else: %>
+ <% for token in @tokens: %>
+ |
+ <%= token.label %> |
+ <%- @humanTime(token.created_at) %> |
+
+
+
+ |
+ <% end %>
+ <% end %>
+
+
+
diff --git a/app/assets/javascripts/app/views/profile/token_access_created.jst.eco b/app/assets/javascripts/app/views/profile/token_access_created.jst.eco
new file mode 100644
index 000000000..a5bca0a9f
--- /dev/null
+++ b/app/assets/javascripts/app/views/profile/token_access_created.jst.eco
@@ -0,0 +1,7 @@
+<%- @T('For security reasons, the API token is shown only once. You\'ll need to copy it somewhere safe before continuing.') %>
+
+
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 40e8ad027..60a9a2597 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -260,6 +260,12 @@ class ApplicationController < ActionController::Base
# check http basic based authentication
authenticate_with_http_basic do |username, password|
logger.debug "http basic auth check '#{username}'"
+ if Setting.get('api_password_access') == false
+ return {
+ auth: false,
+ message: 'API password access disabled!',
+ }
+ end
userdata = User.authenticate(username, password)
next if !userdata
if check_maintenance_only(userdata)
@@ -276,10 +282,10 @@ class ApplicationController < ActionController::Base
}
end
- # check http token based authentication
+ # check http token action based authentication
if auth_param[:token_action]
authenticate_with_http_token do |token, _options|
- logger.debug "token auth check '#{token}'"
+ logger.debug "token action auth check '#{token}'"
userdata = Token.check(
action: auth_param[:token_action],
name: token,
@@ -293,13 +299,41 @@ class ApplicationController < ActionController::Base
end
current_user_set(userdata)
user_device_log(userdata, 'token_auth')
- logger.debug "token auth for '#{userdata.login}'"
+ logger.debug "token action auth for '#{userdata.login}'"
return {
auth: true
}
end
end
+ # check http token based authentication
+ authenticate_with_http_token do |token, _options|
+ logger.debug "token auth check '#{token}'"
+ if Setting.get('api_token_access') == false
+ return {
+ auth: false,
+ message: 'API token access disabled!',
+ }
+ end
+ userdata = Token.check(
+ action: 'api',
+ name: token,
+ )
+ next if !userdata
+ if check_maintenance_only(userdata)
+ return {
+ auth: false,
+ message: 'Maintenance mode enabled!',
+ }
+ end
+ current_user_set(userdata)
+ user_device_log(userdata, 'token_auth')
+ logger.debug "token auth for '#{userdata.login}'"
+ return {
+ auth: true
+ }
+ end
+
logger.debug error_message
{
auth: false,
diff --git a/app/controllers/user_access_token_controller.rb b/app/controllers/user_access_token_controller.rb
new file mode 100644
index 000000000..c1dab3f3e
--- /dev/null
+++ b/app/controllers/user_access_token_controller.rb
@@ -0,0 +1,43 @@
+# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/
+
+class UserAccessTokenController < ApplicationController
+ before_action :authentication_check
+
+ def index
+ tokens = Token.where(action: 'api', persistent: true, user_id: current_user.id).order('updated_at DESC, label ASC')
+ token_list = []
+ tokens.each { |token|
+ attributes = token.attributes
+ attributes.delete('persistent')
+ attributes.delete('name')
+ token_list.push attributes
+ }
+ model_index_render_result(token_list)
+ end
+
+ def create
+ if Setting.get('api_token_access') == false
+ raise Exceptions::UnprocessableEntity, 'API token access disabled!'
+ end
+ if params[:label].empty?
+ raise Exceptions::UnprocessableEntity, 'Need label!'
+ end
+ token = Token.create(
+ action: 'api',
+ label: params[:label],
+ persistent: true,
+ user_id: current_user.id,
+ )
+ render json: {
+ name: token.name,
+ }, status: :ok
+ end
+
+ def destroy
+ token = Token.find_by(action: 'api', user_id: current_user.id, id: params[:id])
+ raise Exceptions::UnprocessableEntity, 'Unable to find api token!' if !token
+ token.destroy
+ render json: {}, status: :ok
+ end
+
+end
diff --git a/app/models/token.rb b/app/models/token.rb
index 625dbaf96..18f039a62 100644
--- a/app/models/token.rb
+++ b/app/models/token.rb
@@ -80,7 +80,7 @@ cleanup old token
def generate_token
loop do
- self.name = SecureRandom.hex(30)
+ self.name = SecureRandom.urlsafe_base64(48)
break if !Token.exists?(name: name)
end
end
diff --git a/config/routes/user_access_token.rb b/config/routes/user_access_token.rb
new file mode 100644
index 000000000..aaad0d1cc
--- /dev/null
+++ b/config/routes/user_access_token.rb
@@ -0,0 +1,9 @@
+Zammad::Application.routes.draw do
+ api_path = Rails.configuration.api_path
+
+ # access token
+ match api_path + '/user_access_token', to: 'user_access_token#index', via: :get
+ match api_path + '/user_access_token', to: 'user_access_token#create', via: :post
+ match api_path + '/user_access_token/:id', to: 'user_access_token#destroy', via: :delete
+
+end
diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb
index 2e57b1d2f..4b5a3738f 100644
--- a/db/migrate/20120101000001_create_base.rb
+++ b/db/migrate/20120101000001_create_base.rb
@@ -188,6 +188,7 @@ class CreateBase < ActiveRecord::Migration
t.boolean :persistent
t.string :name, limit: 100, null: false
t.string :action, limit: 40, null: false
+ t.string :label, limit: 255, null: true
t.timestamps null: false
end
add_index :tokens, :user_id
diff --git a/db/migrate/20160727000001_update_setting_api.rb b/db/migrate/20160727000001_update_setting_api.rb
new file mode 100644
index 000000000..43c3f593c
--- /dev/null
+++ b/db/migrate/20160727000001_update_setting_api.rb
@@ -0,0 +1,53 @@
+class UpdateSettingApi < ActiveRecord::Migration
+ def up
+ # return if it's a new setup
+ return if !Setting.find_by(name: 'system_init_done')
+
+ Setting.create_or_update(
+ title: 'API Token Access',
+ name: 'api_token_access',
+ area: 'API::Base',
+ description: 'Enable REST API using tokens (not username/email addeess and password). Each user need to create own access tokens in user profile.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'api_token_access',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: true,
+ frontend: false
+ )
+ Setting.create_or_update(
+ title: 'API Password Access',
+ name: 'api_password_access',
+ area: 'API::Base',
+ description: 'Enable REST API access using the username/email address and password for the authentication user.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'api_password_access',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: true,
+ frontend: false
+ )
+ add_column :tokens, :label, :string, limit: 255, null: true
+
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index eec1a7cdc..2d1fbd20c 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -554,7 +554,7 @@ Setting.create_if_not_exists(
title: 'Authentication via Google',
name: 'auth_google_oauth2',
area: 'Security::ThirdPartyAuthentication',
- description: 'Enables user authentication via Google.',
+ description: 'Enables user authentication via Google. Register your app first at [Google API Console Site](https://console.developers.google.com/apis/credentials)',
options: {
form: [
{
@@ -601,7 +601,7 @@ Setting.create_if_not_exists(
title: 'Authentication via LinkedIn',
name: 'auth_linkedin',
area: 'Security::ThirdPartyAuthentication',
- description: 'Enables user authentication via LinkedIn.',
+ description: 'Enables user authentication via LinkedIn. Register your app first at [Linkedin Developer Site](https://www.linkedin.com/developer/apps)',
options: {
form: [
{
@@ -1210,6 +1210,51 @@ Setting.create_if_not_exists(
frontend: false
)
+Setting.create_or_update(
+ title: 'API Token Access',
+ name: 'api_token_access',
+ area: 'API::Base',
+ description: 'Enable REST API using tokens (not username/email addeess and password). Each user need to create own access tokens in user profile.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'api_token_access',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: true,
+ frontend: false
+)
+Setting.create_or_update(
+ title: 'API Password Access',
+ name: 'api_password_access',
+ area: 'API::Base',
+ description: 'Enable REST API access using the username/email address and password for the authentication user.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'api_password_access',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: true,
+ frontend: false
+)
+
Setting.create_if_not_exists(
title: 'Enable Chat',
name: 'chat',
diff --git a/test/browser/preferences_test.rb b/test/browser/preferences_test.rb
index eaf9a256e..1b5360c6f 100644
--- a/test/browser/preferences_test.rb
+++ b/test/browser/preferences_test.rb
@@ -27,6 +27,10 @@ class PreferencesTest < TestCase
css: '.content .NavBarProfile',
value: 'Calendar',
)
+ match(
+ css: '.content .NavBarProfile',
+ value: 'Token Access',
+ )
end
def test_permission_customer
@@ -54,9 +58,13 @@ class PreferencesTest < TestCase
css: '.content .NavBarProfile',
value: 'Calendar',
)
+ match_not(
+ css: '.content .NavBarProfile',
+ value: 'Token Access',
+ )
end
- def test_preferences
+ def test_lang_change
@browser = browser_instance
login(
username: 'master@example.com',
@@ -369,4 +377,57 @@ class PreferencesTest < TestCase
value: 'Meine'
)
end
+
+ def test_token_access
+ @browser = browser_instance
+ login(
+ username: 'agent1@example.com',
+ password: 'test',
+ url: browser_url,
+ )
+ tasks_close_all()
+ click(css: 'a[href="#current_user"]')
+ click(css: 'a[href="#profile"]')
+ click(css: 'a[href="#profile/token_access"]')
+
+ set(
+ css: '#content .js-create .js-input',
+ value: 'Some App#1',
+ )
+ click(css: '#content .js-create .js-submit')
+ watch_for(
+ css: '.modal .modal-title',
+ value: 'Your New Personal Access Token'
+ )
+ click(css: '.modal .js-submit')
+ watch_for(
+ css: '#content .js-tokenList',
+ value: 'Some App#1'
+ )
+
+ set(
+ css: '#content .js-create .js-input',
+ value: 'Some App#2',
+ )
+ click(css: '#content .js-create .js-submit')
+ watch_for(
+ css: '.modal .modal-title',
+ value: 'Your New Personal Access Token'
+ )
+ click(css: '.modal .js-submit')
+ watch_for(
+ css: '#content .js-tokenList',
+ value: 'Some App#2'
+ )
+
+ click(css: '#content .js-tokenList a')
+ sleep 1
+ alert = @browser.switch_to.alert
+ alert.accept()
+ watch_for_disappear(
+ css: '#content .js-tokenList',
+ value: 'Some App#2'
+ )
+
+ end
end
diff --git a/test/controllers/api_auth_controller_test.rb b/test/controllers/api_auth_controller_test.rb
new file mode 100644
index 000000000..afd7312bd
--- /dev/null
+++ b/test/controllers/api_auth_controller_test.rb
@@ -0,0 +1,170 @@
+# encoding: utf-8
+require 'test_helper'
+
+class ApiAuthControllerTest < ActionDispatch::IntegrationTest
+ setup do
+
+ # set accept header
+ @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json' }
+
+ # create agent
+ roles = Role.where(name: %w(Admin Agent))
+ groups = Group.all
+
+ UserInfo.current_user_id = 1
+ @admin = User.create_or_update(
+ login: 'api-admin',
+ firstname: 'API',
+ lastname: 'Admin',
+ email: 'api-admin@example.com',
+ password: 'adminpw',
+ active: true,
+ roles: roles,
+ groups: groups,
+ )
+
+ # create agent
+ roles = Role.where(name: 'Agent')
+ @agent = User.create_or_update(
+ login: 'api-agent@example.com',
+ firstname: 'API',
+ lastname: 'Agent',
+ email: 'api-agent@example.com',
+ password: 'agentpw',
+ active: true,
+ roles: roles,
+ groups: groups,
+ )
+
+ # create customer without org
+ roles = Role.where(name: 'Customer')
+ @customer = User.create_or_update(
+ login: 'api-customer1@example.com',
+ firstname: 'API',
+ lastname: 'Customer1',
+ email: 'api-customer1@example.com',
+ password: 'customer1pw',
+ active: true,
+ roles: roles,
+ )
+
+ end
+
+ test 'basic auth - admin' do
+
+ admin_credentials = ActionController::HttpAuthentication::Basic.encode_credentials('api-admin@example.com', 'adminpw')
+
+ Setting.set('api_password_access', false)
+ get '/api/v1/settings', {}, @headers.merge('Authorization' => admin_credentials)
+ assert_response(401)
+
+ Setting.set('api_password_access', true)
+ get '/api/v1/settings', {}, @headers.merge('Authorization' => admin_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+ test 'basic auth - agent' do
+
+ agent_credentials = ActionController::HttpAuthentication::Basic.encode_credentials('api-agent@example.com', 'agentpw')
+
+ Setting.set('api_password_access', false)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => agent_credentials)
+ assert_response(401)
+
+ Setting.set('api_password_access', true)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => agent_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+ test 'basic auth - customer' do
+
+ customer_credentials = ActionController::HttpAuthentication::Basic.encode_credentials('api-customer1@example.com', 'customer1pw')
+
+ Setting.set('api_password_access', false)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => customer_credentials)
+ assert_response(401)
+
+ Setting.set('api_password_access', true)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => customer_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+ test 'token auth - admin' do
+
+ admin_token = Token.create(
+ action: 'api',
+ persistent: true,
+ user_id: @admin.id,
+ )
+ admin_credentials = "Token token=#{admin_token.name}"
+
+ Setting.set('api_token_access', false)
+ get '/api/v1/settings', {}, @headers.merge('Authorization' => admin_credentials)
+ assert_response(401)
+
+ Setting.set('api_token_access', true)
+ get '/api/v1/settings', {}, @headers.merge('Authorization' => admin_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+ test 'token auth - agent' do
+
+ agent_token = Token.create(
+ action: 'api',
+ persistent: true,
+ user_id: @agent.id,
+ )
+ agent_credentials = "Token token=#{agent_token.name}"
+
+ Setting.set('api_token_access', false)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => agent_credentials)
+ assert_response(401)
+
+ Setting.set('api_token_access', true)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => agent_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+ test 'token auth - customer' do
+
+ customer_token = Token.create(
+ action: 'api',
+ persistent: true,
+ user_id: @customer.id,
+ )
+ customer_credentials = "Token token=#{customer_token.name}"
+
+ Setting.set('api_token_access', false)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => customer_credentials)
+ assert_response(401)
+
+ Setting.set('api_token_access', true)
+ get '/api/v1/tickets', {}, @headers.merge('Authorization' => customer_credentials)
+ assert_response(200)
+ result = JSON.parse(@response.body)
+ assert_equal(Array, result.class)
+ assert(result)
+
+ end
+
+end
diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb
index bdb6ccb4e..8cfbfb393 100644
--- a/test/controllers/settings_controller_test.rb
+++ b/test/controllers/settings_controller_test.rb
@@ -13,10 +13,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
UserInfo.current_user_id = 1
@admin = User.create_or_update(
- login: 'packages-admin',
- firstname: 'Packages',
+ login: 'setting-admin',
+ firstname: 'Setting',
lastname: 'Admin',
- email: 'packages-admin@example.com',
+ email: 'setting-admin@example.com',
password: 'adminpw',
active: true,
roles: roles,
@@ -26,10 +26,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
# create agent
roles = Role.where(name: 'Agent')
@agent = User.create_or_update(
- login: 'packages-agent@example.com',
- firstname: 'Rest',
+ login: 'setting-agent@example.com',
+ firstname: 'Setting',
lastname: 'Agent',
- email: 'packages-agent@example.com',
+ email: 'setting-agent@example.com',
password: 'agentpw',
active: true,
roles: roles,
@@ -39,10 +39,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
# create customer without org
roles = Role.where(name: 'Customer')
@customer_without_org = User.create_or_update(
- login: 'packages-customer1@example.com',
- firstname: 'Packages',
+ login: 'setting-customer1@example.com',
+ firstname: 'Setting',
lastname: 'Customer1',
- email: 'packages-customer1@example.com',
+ email: 'setting-customer1@example.com',
password: 'customer1pw',
active: true,
roles: roles,
@@ -63,7 +63,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with admin' do
- credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-admin@example.com', 'adminpw')
+ credentials = ActionController::HttpAuthentication::Basic.encode_credentials('setting-admin@example.com', 'adminpw')
# index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)
@@ -76,7 +76,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with agent' do
- credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-agent@example.com', 'adminpw')
+ credentials = ActionController::HttpAuthentication::Basic.encode_credentials('setting-agent@example.com', 'agentpw')
# index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)
@@ -89,7 +89,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with customer' do
- credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-customer1@example.com', 'customer1pw')
+ credentials = ActionController::HttpAuthentication::Basic.encode_credentials('setting-customer1@example.com', 'customer1pw')
# index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)
diff --git a/test/unit/token_test.rb b/test/unit/token_test.rb
index 2133bd1e0..9e5504d0c 100644
--- a/test/unit/token_test.rb
+++ b/test/unit/token_test.rb
@@ -86,20 +86,20 @@ class TokenTest < ActiveSupport::TestCase
if test[:result] == true
if !user
- assert( false, test[:test_name] + ': token verification failed' )
+ assert(false, test[:test_name] + ': token verification failed')
else
test[:verify].each { |key, value|
- assert_equal( user[key], value, 'verify' )
+ assert_equal(user[key], value, 'verify')
}
end
else
- assert_equal( test[:result], user, test[:test_name] + ': failed or not existing' )
+ assert_equal(test[:result], user, test[:test_name] + ': failed or not existing')
end
if test[:name]
#puts test[:test_name] + ': deleting token '+ test[:name]
- token = Token.where( name: test[:name] ).first
+ token = Token.find_by(name: test[:name])
if token
token.destroy