Added api setting to admin interface and tokens to user preferences.

This commit is contained in:
Martin Edenhofer 2016-07-28 12:09:32 +02:00
parent 42d0cd1f8f
commit e200378dd6
23 changed files with 617 additions and 39 deletions

View file

@ -66,7 +66,7 @@ class CalendarSubscriptions extends App.Controller
@ajax( @ajax(
id: 'preferences' id: 'preferences'
type: 'PUT' type: 'PUT'
url: @apiPath + '/users/preferences' url: "#{@apiPath}/users/preferences"
data: JSON.stringify data data: JSON.stringify data
success: @success success: @success
error: @error error: @error

View file

@ -15,12 +15,17 @@ class Index extends App.Controller
) )
# fetch data, render view # fetch data, render view
load: => load: (force = false) =>
@ajax( @ajax(
id: 'user_devices' id: 'user_devices'
type: 'GET' type: 'GET'
url: "#{@apiPath}/user_devices" url: "#{@apiPath}/user_devices"
success: (data) => success: (data) =>
# verify is rerender is needed
if !force && @lastestUpdated && data && data[0] && @lastestUpdated.updated_at is data[0].updated_at
return
@lastestUpdated = data[0]
@data = data @data = data
@render() @render()
) )
@ -39,7 +44,8 @@ class Index extends App.Controller
type: 'DELETE' type: 'DELETE'
url: "#{@apiPath}/user_devices/#{id}" url: "#{@apiPath}/user_devices/#{id}"
processData: true processData: true
success: @load success: =>
@load(true)
error: @error error: @error
) )

View file

@ -0,0 +1,92 @@
class Index extends App.Controller
events:
'click [data-type=delete]': 'delete'
'submit form.js-create': 'create'
constructor: ->
super
return if !@authenticate()
@title 'Token Access', true
@load()
@interval(
=>
@load()
12000
)
# fetch data, render view
load: (force = false) =>
@ajax(
id: 'user_access_token'
type: 'GET'
url: "#{@apiPath}/user_access_token"
success: (data) =>
# verify is rerender is needed
if !force && @lastestUpdated && data && data[0] && @lastestUpdated.updated_at is data[0].updated_at
return
@lastestUpdated = data[0]
@data = data
@render()
)
render: =>
@html App.view('profile/token_access')(
tokens: @data
)
create: (e) =>
e.preventDefault()
params = @formParam(e.target)
@ajax(
id: 'user_access_token_create'
type: 'POST'
url: "#{@apiPath}/user_access_token"
data: JSON.stringify(params)
processData: true
success: @show
error: @error
)
show: (data) =>
@load()
ui = @
new App.ControllerModal(
head: 'Your New Personal Access Token'
buttonSubmit: 'OK, I\'ve copied my token'
content: ->
App.view('profile/token_access_created')(
name: data.name
)
post: ->
@el.find('.js-select').on('click', ui.selectAll)
onCancel: ->
@close()
onSubmit: ->
@close()
)
delete: (e) =>
e.preventDefault()
return if !confirm(App.i18n.translateInline('Sure?'))
id = $(e.target).closest('a').data('token-id')
@ajax(
id: 'user_access_token_delete'
type: 'DELETE'
url: "#{@apiPath}/user_access_token/#{id}"
processData: true
success: =>
@load(true)
error: @error
)
error: (xhr, status, error) =>
data = JSON.parse(xhr.responseText)
@notify(
type: 'error'
msg: App.i18n.translateContent(data.message)
)
App.Config.set('Token Access', { prio: 3200, name: 'Token Access', parent: '#profile', target: '#profile/token_access', controller: Index, role: [ 'Agent', 'Admin' ] }, 'NavBarProfile')

View file

@ -58,4 +58,4 @@ class Index extends App.ControllerContent
@load() @load()
) )
App.Config.set('Packages', { prio: 1000, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, role: ['Admin'] }, 'NavBarAdmin') App.Config.set('Packages', { prio: 3600, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, role: ['Admin'] }, 'NavBarAdmin')

View file

@ -53,4 +53,4 @@ class Index extends App.ControllerContent
@load() @load()
) )
App.Config.set('Session', { prio: 3700, name: 'Sessions', parent: '#system', target: '#system/sessions', controller: Index, role: ['Admin'] }, 'NavBarAdmin' ) App.Config.set('Session', { prio: 3800, name: 'Sessions', parent: '#system', target: '#system/sessions', controller: Index, role: ['Admin'] }, 'NavBarAdmin' )

View file

@ -63,8 +63,8 @@ class Widget extends App.Controller
App.i18n.setMap(source, translation_new) App.i18n.setMap(source, translation_new)
# replace rest in page # replace rest in page
source = source.replace('\'', '\\\'') sourceQuoted = source.replace('\'', '\\\'')
$(".translation[title='#{source}']").text(translation_new) $(".translation[title='#{sourceQuoted}']").text(translation_new)
# update permanent translation mapString # update permanent translation mapString
translation = App.Translation.findByAttribute('source', source) translation = App.Translation.findByAttribute('source', source)

View file

@ -0,0 +1,57 @@
<div class="page-header">
<div class="page-header-title"><h1><%- @T('Token Access') %></h1></div>
</div>
<p><%- @T('You can generate a personal access token for each application you use that needs access to the Zammad API.') %></p>
<h2><%- @T('Add a Personal Access Token') %></h2>
<p><%- @T('Pick a name for the application, and we\'ll give you a unique token.') %></p>
<form class="page-content js-create">
<div class="input form-group">
<div class="formGroup-label">
<label for="token-label"><%- @T('Name') %></label>
</div>
<div class="controls"><input id="token-label" type="text" name="label" value="" class="form-control js-input" required></div>
</div>
<button class="btn btn--primary js-submit"><%- @T('Create') %></button>
</form>
<hr>
<h2><%- @T('Personal Access Tokens') %></h2>
<table class="settings-list js-tokenList">
<thead>
<tr>
<th><%- @T('Name') %></th>
<th><%- @T('Created') %></th>
<!--
<th><%- @T('Expires') %></th>
<th><%- @T('Last used') %></th>
-->
<th><%- @T('Delete') %></th>
</tr>
</thead>
<tbody>
<% if _.isEmpty(@tokens): %>
<tr>
<td colspan="3"><%- @T('none') %>
<% else: %>
<% for token in @tokens: %>
<tr>
<td><%= token.label %></td>
<td><%- @humanTime(token.created_at) %></td>
<!--
<td><%- @humanTime(token.expired_at) %></td>
<td><%- @humanTime(token.last_used_at) %></td>
-->
<td class="settings-list-controls">
<div>
<a class="settings-list-control" href="#" data-token-id="<%- token.id %>" data-type="delete" title="<%- @Ti('Delete') %>"><%- @Icon('trash') %></a>
</div>
</tr>
<% end %>
<% end %>
</tbody>
</table>

View file

@ -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.') %>
<form>
<div class="input form-group">
<div class="controls"><input type="text" value="<%= @name %>" class="form-control input js-select" readonly></div>
</div>
</form>

View file

@ -260,6 +260,12 @@ class ApplicationController < ActionController::Base
# check http basic based authentication # check http basic based authentication
authenticate_with_http_basic do |username, password| authenticate_with_http_basic do |username, password|
logger.debug "http basic auth check '#{username}'" 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) userdata = User.authenticate(username, password)
next if !userdata next if !userdata
if check_maintenance_only(userdata) if check_maintenance_only(userdata)
@ -276,10 +282,10 @@ class ApplicationController < ActionController::Base
} }
end end
# check http token based authentication # check http token action based authentication
if auth_param[:token_action] if auth_param[:token_action]
authenticate_with_http_token do |token, _options| authenticate_with_http_token do |token, _options|
logger.debug "token auth check '#{token}'" logger.debug "token action auth check '#{token}'"
userdata = Token.check( userdata = Token.check(
action: auth_param[:token_action], action: auth_param[:token_action],
name: token, name: token,
@ -293,12 +299,40 @@ class ApplicationController < ActionController::Base
end end
current_user_set(userdata) current_user_set(userdata)
user_device_log(userdata, 'token_auth') user_device_log(userdata, 'token_auth')
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}'" logger.debug "token auth for '#{userdata.login}'"
return { return {
auth: true auth: true
} }
end end
end
logger.debug error_message logger.debug error_message
{ {

View file

@ -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

View file

@ -80,7 +80,7 @@ cleanup old token
def generate_token def generate_token
loop do loop do
self.name = SecureRandom.hex(30) self.name = SecureRandom.urlsafe_base64(48)
break if !Token.exists?(name: name) break if !Token.exists?(name: name)
end end
end end

View file

@ -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

View file

@ -188,6 +188,7 @@ class CreateBase < ActiveRecord::Migration
t.boolean :persistent t.boolean :persistent
t.string :name, limit: 100, null: false t.string :name, limit: 100, null: false
t.string :action, limit: 40, null: false t.string :action, limit: 40, null: false
t.string :label, limit: 255, null: true
t.timestamps null: false t.timestamps null: false
end end
add_index :tokens, :user_id add_index :tokens, :user_id

View file

@ -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

View file

@ -554,7 +554,7 @@ Setting.create_if_not_exists(
title: 'Authentication via Google', title: 'Authentication via Google',
name: 'auth_google_oauth2', name: 'auth_google_oauth2',
area: 'Security::ThirdPartyAuthentication', 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: { options: {
form: [ form: [
{ {
@ -601,7 +601,7 @@ Setting.create_if_not_exists(
title: 'Authentication via LinkedIn', title: 'Authentication via LinkedIn',
name: 'auth_linkedin', name: 'auth_linkedin',
area: 'Security::ThirdPartyAuthentication', 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: { options: {
form: [ form: [
{ {
@ -1210,6 +1210,51 @@ Setting.create_if_not_exists(
frontend: false 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( Setting.create_if_not_exists(
title: 'Enable Chat', title: 'Enable Chat',
name: 'chat', name: 'chat',

View file

@ -27,6 +27,10 @@ class PreferencesTest < TestCase
css: '.content .NavBarProfile', css: '.content .NavBarProfile',
value: 'Calendar', value: 'Calendar',
) )
match(
css: '.content .NavBarProfile',
value: 'Token Access',
)
end end
def test_permission_customer def test_permission_customer
@ -54,9 +58,13 @@ class PreferencesTest < TestCase
css: '.content .NavBarProfile', css: '.content .NavBarProfile',
value: 'Calendar', value: 'Calendar',
) )
match_not(
css: '.content .NavBarProfile',
value: 'Token Access',
)
end end
def test_preferences def test_lang_change
@browser = browser_instance @browser = browser_instance
login( login(
username: 'master@example.com', username: 'master@example.com',
@ -369,4 +377,57 @@ class PreferencesTest < TestCase
value: 'Meine' value: 'Meine'
) )
end 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 end

View file

@ -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

View file

@ -13,10 +13,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
UserInfo.current_user_id = 1 UserInfo.current_user_id = 1
@admin = User.create_or_update( @admin = User.create_or_update(
login: 'packages-admin', login: 'setting-admin',
firstname: 'Packages', firstname: 'Setting',
lastname: 'Admin', lastname: 'Admin',
email: 'packages-admin@example.com', email: 'setting-admin@example.com',
password: 'adminpw', password: 'adminpw',
active: true, active: true,
roles: roles, roles: roles,
@ -26,10 +26,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
# create agent # create agent
roles = Role.where(name: 'Agent') roles = Role.where(name: 'Agent')
@agent = User.create_or_update( @agent = User.create_or_update(
login: 'packages-agent@example.com', login: 'setting-agent@example.com',
firstname: 'Rest', firstname: 'Setting',
lastname: 'Agent', lastname: 'Agent',
email: 'packages-agent@example.com', email: 'setting-agent@example.com',
password: 'agentpw', password: 'agentpw',
active: true, active: true,
roles: roles, roles: roles,
@ -39,10 +39,10 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
# create customer without org # create customer without org
roles = Role.where(name: 'Customer') roles = Role.where(name: 'Customer')
@customer_without_org = User.create_or_update( @customer_without_org = User.create_or_update(
login: 'packages-customer1@example.com', login: 'setting-customer1@example.com',
firstname: 'Packages', firstname: 'Setting',
lastname: 'Customer1', lastname: 'Customer1',
email: 'packages-customer1@example.com', email: 'setting-customer1@example.com',
password: 'customer1pw', password: 'customer1pw',
active: true, active: true,
roles: roles, roles: roles,
@ -63,7 +63,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with admin' do 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 # index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)
@ -76,7 +76,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with agent' do 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 # index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)
@ -89,7 +89,7 @@ class SettingsControllerTest < ActionDispatch::IntegrationTest
test 'settings index with customer' do 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 # index
get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials)

View file

@ -99,7 +99,7 @@ class TokenTest < ActiveSupport::TestCase
if test[:name] if test[:name]
#puts test[:test_name] + ': deleting token '+ 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 if token
token.destroy token.destroy