From 84502312e413071740aa350fd2c7d05928886b8c Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Fri, 14 Aug 2015 03:58:00 +0200 Subject: [PATCH 01/35] Added custom error messages. --- public/404.html | 32 ++----- public/422.html | 32 ++----- public/500.html | 31 ++----- public/assets/error/error-1.svg | 1 + public/assets/error/error-2.svg | 1 + public/assets/error/error-3.svg | 1 + public/assets/error/error-4.svg | 1 + .../assets/error/firasans-regular-webfont.eot | Bin 0 -> 26030 bytes .../assets/error/firasans-regular-webfont.ttf | Bin 0 -> 72228 bytes .../error/firasans-regular-webfont.woff | Bin 0 -> 28852 bytes public/assets/error/style.css | 80 ++++++++++++++++++ 11 files changed, 108 insertions(+), 71 deletions(-) create mode 100644 public/assets/error/error-1.svg create mode 100644 public/assets/error/error-2.svg create mode 100644 public/assets/error/error-3.svg create mode 100644 public/assets/error/error-4.svg create mode 100755 public/assets/error/firasans-regular-webfont.eot create mode 100755 public/assets/error/firasans-regular-webfont.ttf create mode 100755 public/assets/error/firasans-regular-webfont.woff create mode 100644 public/assets/error/style.css diff --git a/public/404.html b/public/404.html index 9a48320a5..653716afa 100644 --- a/public/404.html +++ b/public/404.html @@ -1,26 +1,10 @@ - - - The page you were looking for doesn't exist (404) - - + + +404: Not Found + - - -
-

The page you were looking for doesn't exist.

-

You may have mistyped the address or the page may have moved.

-
- - +

404: Requested Page was not found.

+
+

Sorry, but the Phoenix is not able to find your page. Try checking the URL for errors.

+ \ No newline at end of file diff --git a/public/422.html b/public/422.html index 83660ab18..42db85126 100644 --- a/public/422.html +++ b/public/422.html @@ -1,26 +1,10 @@ - - - The change you wanted was rejected (422) - - + + +422: Not Found + - - -
-

The change you wanted was rejected.

-

Maybe you tried to change something you didn't have access to.

-
- - +

422: The change you wanted was rejected.

+
+

Maybe you tried to change something you didn't have access to.

+ \ No newline at end of file diff --git a/public/500.html b/public/500.html index f3648a0db..a07e0040c 100644 --- a/public/500.html +++ b/public/500.html @@ -1,25 +1,10 @@ - - - We're sorry, but something went wrong (500) - - + + +500: Something went wrong + - - -
-

We're sorry, but something went wrong.

-
- - +

500: We're sorry, but something went wrong.

+
+

We're sorry, but something went wrong.

+ \ No newline at end of file diff --git a/public/assets/error/error-1.svg b/public/assets/error/error-1.svg new file mode 100644 index 000000000..da0e1e7f7 --- /dev/null +++ b/public/assets/error/error-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/error/error-2.svg b/public/assets/error/error-2.svg new file mode 100644 index 000000000..15c6c5d56 --- /dev/null +++ b/public/assets/error/error-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/error/error-3.svg b/public/assets/error/error-3.svg new file mode 100644 index 000000000..32a3639d1 --- /dev/null +++ b/public/assets/error/error-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/error/error-4.svg b/public/assets/error/error-4.svg new file mode 100644 index 000000000..aec67fef2 --- /dev/null +++ b/public/assets/error/error-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/error/firasans-regular-webfont.eot b/public/assets/error/firasans-regular-webfont.eot new file mode 100755 index 0000000000000000000000000000000000000000..e87652e08b609a6fc9184dd976becb8070b2dad5 GIT binary patch literal 26030 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 gJtMlQK+YEZOMyO+TZeKJUJF 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
;{tE-UOR(O0X{So;n43b(- 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 S!o}E%qi_UL<$G4{Wwc^ZHK literal 0 HcmV?d00001 diff --git a/public/assets/error/firasans-regular-webfont.woff b/public/assets/error/firasans-regular-webfont.woff new file mode 100755 index 0000000000000000000000000000000000000000..f9285617c971eb9e8f4555cd464526e1beb9a5ce GIT binary patch literal 28852 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 L00C3P;+X&dNQ!N| literal 0 HcmV?d00001 diff --git a/public/assets/error/style.css b/public/assets/error/style.css new file mode 100644 index 000000000..1149427d4 --- /dev/null +++ b/public/assets/error/style.css @@ -0,0 +1,80 @@ +@font-face { + font-family: 'Fira Sans'; + src: url('firasans-regular-webfont.eot'); + src: url('firasans-regular-webfont.eot?#iefix') format('embedded-opentype'), + url('firasans-regular-webfont.woff') format('woff'), + url('firasans-regular-webfont.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +html { + font-family: "Fira Sans"; + height: 100%; + color: #8c959c; + background: #f8f9fa; + text-align: center; +} + +.dark { + background: #444a4f; + color: #919497; +} + +body { + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + height: 100%; + min-height: 600px; + margin: 0; + padding: 40px 10px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + overflow-x: hidden; +} + +h1 { + margin: 0; + color: #444a4f; + max-width: 450px; +} + +.dark h1 { + color: white; +} + +a { + color: #f1d158; + text-decoration: none; +} +a:visited { + color: #ccb250; +} + +ul { + text-align: left; +} + +p { + max-width: 400px; + margin: 0 0 20px; +} + +.error-image { + height: 650px; + width: 100%; + margin: 30px 0; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} \ No newline at end of file From 9e38203af18e8a49093f236779fa51a990daff62 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 15 Aug 2015 09:48:08 +0200 Subject: [PATCH 02/35] Reduce locales for test env. --- app/models/translation.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/translation.rb b/app/models/translation.rb index 2f4d9cb58..0d1b94693 100644 --- a/app/models/translation.rb +++ b/app/models/translation.rb @@ -15,7 +15,11 @@ load translations from online =end def self.load - Locale.where(active: true).each {|locale| + locales = Locale.where(active: true) + if Rails.env.test? + locales = Locale.where(active: true, name: ['en-us', 'de-de']) + end + locales.each {|locale| url = "https://i18n.zammad.com/api/v1/translations/#{locale.locale}" if !UserInfo.current_user_id UserInfo.current_user_id = 1 From 4e8ee5159ef5214fda3a6aa763d81daca06f6906 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 15 Aug 2015 09:49:15 +0200 Subject: [PATCH 03/35] Moved to separate controller tests. --- test/controllers/packages_controller_test.rb | 92 +++++ test/controllers/settings_controller_test.rb | 92 +++++ .../user_organization_controller_test.rb | 325 ++++++++++++++++++ test/unit/rest_test.rb | 244 ------------- 4 files changed, 509 insertions(+), 244 deletions(-) create mode 100644 test/controllers/packages_controller_test.rb create mode 100644 test/controllers/settings_controller_test.rb create mode 100644 test/controllers/user_organization_controller_test.rb delete mode 100644 test/unit/rest_test.rb diff --git a/test/controllers/packages_controller_test.rb b/test/controllers/packages_controller_test.rb new file mode 100644 index 000000000..12d938b53 --- /dev/null +++ b/test/controllers/packages_controller_test.rb @@ -0,0 +1,92 @@ +# encoding: utf-8 +require 'test_helper' + +class PackagesControllerTest < 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: 'packages-admin', + firstname: 'Packages', + lastname: 'Admin', + email: 'packages-admin@example.com', + password: 'adminpw', + active: true, + roles: roles, + groups: groups, + ) + + # create agent + roles = Role.where( name: 'Agent' ) + @agent = User.create_or_update( + login: 'packages-agent@example.com', + firstname: 'Rest', + lastname: 'Agent', + email: 'packages-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + + # create customer without org + roles = Role.where( name: 'Customer' ) + @customer_without_org = User.create_or_update( + login: 'packages-customer1@example.com', + firstname: 'Packages', + lastname: 'Customer1', + email: 'packages-customer1@example.com', + password: 'customer1pw', + active: true, + roles: roles, + ) + + end + + test 'packages index with admin' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-admin@example.com', 'adminpw') + + # index + get '/api/v1/packages', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result['packages']) + + end + + test 'packages index with agent' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-agent@example.com', 'adminpw') + + # index + get '/api/v1/packages', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_not(result['packages']) + + end + + test 'packages index with customer' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-customer1@example.com', 'customer1pw') + + # index + get '/api/v1/packages', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_not(result['packages']) + + end + +end diff --git a/test/controllers/settings_controller_test.rb b/test/controllers/settings_controller_test.rb new file mode 100644 index 000000000..984b18378 --- /dev/null +++ b/test/controllers/settings_controller_test.rb @@ -0,0 +1,92 @@ +# encoding: utf-8 +require 'test_helper' + +class SettingsControllerTest < 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: 'packages-admin', + firstname: 'Packages', + lastname: 'Admin', + email: 'packages-admin@example.com', + password: 'adminpw', + active: true, + roles: roles, + groups: groups, + ) + + # create agent + roles = Role.where( name: 'Agent' ) + @agent = User.create_or_update( + login: 'packages-agent@example.com', + firstname: 'Rest', + lastname: 'Agent', + email: 'packages-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + + # create customer without org + roles = Role.where( name: 'Customer' ) + @customer_without_org = User.create_or_update( + login: 'packages-customer1@example.com', + firstname: 'Packages', + lastname: 'Customer1', + email: 'packages-customer1@example.com', + password: 'customer1pw', + active: true, + roles: roles, + ) + + end + + test 'settings index with admin' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-admin@example.com', 'adminpw') + + # index + get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(Array, result.class) + assert(result) + + end + + test 'settings index with agent' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-agent@example.com', 'adminpw') + + # index + get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_not(result['settings']) + + end + + test 'settings index with customer' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('packages-customer1@example.com', 'customer1pw') + + # index + get '/api/v1/settings', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_not(result['settings']) + + end + +end diff --git a/test/controllers/user_organization_controller_test.rb b/test/controllers/user_organization_controller_test.rb new file mode 100644 index 000000000..bce04cc70 --- /dev/null +++ b/test/controllers/user_organization_controller_test.rb @@ -0,0 +1,325 @@ +# encoding: utf-8 +require 'test_helper' + +class UserOrganizationControllerTest < 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: 'rest-admin', + firstname: 'Rest', + lastname: 'Agent', + email: 'rest-admin@example.com', + password: 'adminpw', + active: true, + roles: roles, + groups: groups, + ) + + # create agent + roles = Role.where( name: 'Agent' ) + @agent = User.create_or_update( + login: 'rest-agent@example.com', + firstname: 'Rest', + lastname: 'Agent', + email: 'rest-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + + # create customer without org + roles = Role.where( name: 'Customer' ) + @customer_without_org = User.create_or_update( + login: 'rest-customer1@example.com', + firstname: 'Rest', + lastname: 'Customer1', + email: 'rest-customer1@example.com', + password: 'customer1pw', + active: true, + roles: roles, + ) + + # create orgs + @organization = Organization.create_or_update( + name: 'Rest Org', + ) + @organization2 = Organization.create_or_update( + name: 'Rest Org #2', + ) + @organization3 = Organization.create_or_update( + name: 'Rest Org #3', + ) + + # create customer with org + @customer_with_org = User.create_or_update( + login: 'rest-customer2@example.com', + firstname: 'Rest', + lastname: 'Customer2', + email: 'rest-customer2@example.com', + password: 'customer2pw', + active: true, + roles: roles, + organization_id: @organization.id, + ) + + end + + test 'user create tests - no user' do + + # create user with disabled feature + Setting.set('user_create_account', false) + post '/api/v1/users', {}, @headers + assert_response(422) + result = JSON.parse(@response.body) + assert(result['error']) + assert_equal('Feature not enabled!', result['error']) + + # already existing user with enabled feature + Setting.set('user_create_account', true) + params = { email: 'rest-customer1@example.com' } + post '/api/v1/users', params.to_json, @headers + assert_response(422) + result = JSON.parse(@response.body) + assert(result['error']) + assert_equal('User already exists!', result['error']) + + # create user with enabled feature + params = { firstname: 'Me First', lastname: 'Me Last', email: 'new_here@example.com' } + post '/api/v1/users', params.to_json, @headers + assert_response(201) + result = JSON.parse(@response.body) + assert(result) + + assert_equal('Me First', result['firstname']) + assert_equal('Me Last', result['lastname']) + assert_equal('new_here@example.com', result['login']) + assert_equal('new_here@example.com', result['email']) + + # no user + get '/api/v1/users', {}, @headers + assert_response(401) + result = JSON.parse(@response.body) + assert_equal('authentication failed', result['error']) + end + + test 'auth tests - not existing user' do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('not_existing@example.com', 'adminpw') + + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal('authentication failed', result['error']) + end + + test 'auth tests - username auth, wrong pw' do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-admin', 'not_existing') + + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal('authentication failed', result['error']) + end + + test 'auth tests - email auth, wrong pw' do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-admin@example.com', 'not_existing') + + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal('authentication failed', result['error']) + end + + test 'auth tests - username auth' do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-admin', 'adminpw') + + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + end + + test 'auth tests - email auth' do + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-admin@example.com', 'adminpw') + + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + end + + test 'user index with admin' do + + # email auth + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-admin@example.com', 'adminpw') + + # index + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + + # index + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + assert_equal(result.class, Array) + assert(result.length >= 3) + + # show/:id + get "/api/v1/users/#{@agent.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + assert_equal(result.class, Hash) + assert_equal(result['email'], 'rest-agent@example.com') + + get "/api/v1/users/#{@customer_without_org.id}", {}, 'Authorization' => credentials + assert_response(200) + result = JSON.parse(@response.body) + assert(result) + assert_equal(result.class, Hash) + assert_equal(result['email'], 'rest-customer1@example.com') + + end + + test 'user index with customer1' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-customer1@example.com', 'customer1pw') + + # index + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Array) + assert_equal(result.length, 1) + + # show/:id + get "/api/v1/users/#{@customer_without_org.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['email'], 'rest-customer1@example.com') + + get "/api/v1/users/#{@customer_with_org.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result.empty?) + + end + + test 'user index with customer2' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-customer2@example.com', 'customer2pw') + + # index + get '/api/v1/users', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Array) + assert_equal(result.length, 1) + + # show/:id + get "/api/v1/users/#{@customer_with_org.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert_equal(result['email'], 'rest-customer2@example.com') + + get "/api/v1/users/#{@customer_without_org.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(401) + #puts @response.body + result = JSON.parse(@response.body) + assert_equal(result.class, Hash) + assert(result.empty?) + + end + + test 'organization index with agent' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-agent@example.com', 'agentpw') + + # index + get '/api/v1/organizations', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Array) + assert(result.length >= 3) + + # show/:id + get "/api/v1/organizations/#{@organization.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], 'Rest Org') + + get "/api/v1/organizations/#{@organization2.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], 'Rest Org #2') + + end + + test 'organization index with customer1' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-customer1@example.com', 'customer1pw') + + # index + get '/api/v1/organizations', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Array) + assert_equal(result.length, 0) + + # show/:id + get "/api/v1/organizations/#{@organization.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], nil) + + get "/api/v1/organizations/#{@organization2.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], nil) + + end + + test 'organization index with customer2' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('rest-customer2@example.com', 'customer2pw') + + # index + get '/api/v1/organizations', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal(result.class, Array) + assert_equal(result.length, 1) + + # show/:id + get "/api/v1/organizations/#{@organization.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(200) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], 'Rest Org') + + get "/api/v1/organizations/#{@organization2.id}", {}, @headers.merge('Authorization' => credentials) + assert_response(401) + result = JSON.parse(@response.body) + assert_equal( result.class, Hash) + assert_equal( result['name'], nil) + + end +end diff --git a/test/unit/rest_test.rb b/test/unit/rest_test.rb deleted file mode 100644 index 4bc121fdd..000000000 --- a/test/unit/rest_test.rb +++ /dev/null @@ -1,244 +0,0 @@ -# encoding: utf-8 -require 'test_helper' - -class RestTest < ActiveSupport::TestCase - - test 'users and orgs' do - - if !ENV['BROWSER_URL'] - puts 'NOTICE: Do not execute rest tests, no BROWSER_URL=http://some_host:port is defined! e. g. export BROWSER_URL=http://localhost:3000' - return - end - - # create agent - roles = Role.where( name: %w(Admin Agent) ) - groups = Group.all - - UserInfo.current_user_id = 1 - admin = User.create_or_update( - login: 'rest-admin', - firstname: 'Rest', - lastname: 'Agent', - email: 'rest-admin@example.com', - password: 'adminpw', - active: true, - roles: roles, - groups: groups, - ) - - # create agent - roles = Role.where( name: 'Agent' ) - agent = User.create_or_update( - login: 'rest-agent@example.com', - firstname: 'Rest', - lastname: 'Agent', - email: 'rest-agent@example.com', - password: 'agentpw', - active: true, - roles: roles, - groups: groups, - ) - - # create customer without org - roles = Role.where( name: 'Customer' ) - customer_without_org = User.create_or_update( - login: 'rest-customer1@example.com', - firstname: 'Rest', - lastname: 'Customer1', - email: 'rest-customer1@example.com', - password: 'customer1pw', - active: true, - roles: roles, - ) - - # create orgs - organization = Organization.create_or_update( - name: 'Rest Org', - ) - organization2 = Organization.create_or_update( - name: 'Rest Org #2', - ) - organization3 = Organization.create_or_update( - name: 'Rest Org #3', - ) - - # create customer with org - customer_with_org = User.create_or_update( - login: 'rest-customer2@example.com', - firstname: 'Rest', - lastname: 'Customer2', - email: 'rest-customer2@example.com', - password: 'customer2pw', - active: true, - roles: roles, - organization_id: organization.id, - ) - - # not existing user - request = get( 'not_existing@example.com', 'adminpw', '/api/v1/users') - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # username auth, wrong pw - request = get( 'rest-admin', 'not_existing', '/api/v1/users' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # email auth, wrong pw - request = get( 'rest-admin@example.com', 'not_existing', '/api/v1/users' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # username auth - request = get( 'rest-admin', 'adminpw', '/api/v1/users' ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - - # email auth - request = get( 'rest-admin@example.com', 'adminpw', '/api/v1/users' ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - - # /users - - # index - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/users') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert( request[:data].length >= 3 ) - - # show/:id - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/users/' + agent.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['email'], 'rest-agent@example.com') - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/users/' + customer_without_org.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['email'], 'rest-customer1@example.com') - - # index - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/users') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert_equal( request[:data].length, 1 ) - - # show/:id - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/users/' + customer_without_org.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['email'], 'rest-customer1@example.com') - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/users/' + customer_with_org.id.to_s ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # index - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/users') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert_equal( request[:data].length, 1 ) - - # show/:id - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/users/' + customer_with_org.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['email'], 'rest-customer2@example.com') - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/users/' + customer_without_org.id.to_s ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # /organizations - - # index - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/organizations') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert( request[:data].length >= 3 ) - - # show/:id - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/organizations/' + organization.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['name'], 'Rest Org') - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/organizations/' + organization2.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['name'], 'Rest Org #2') - - # index - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/organizations') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert_equal( request[:data].length, 0 ) - - # show/:id - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/organizations/' + organization.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['name'], nil) - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/organizations/' + organization2.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['name'], nil) - - # index - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/organizations') - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert_equal( request[:data].length, 1 ) - - # show/:id - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/organizations/' + organization.id.to_s ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert_equal( request[:data]['name'], 'Rest Org') - request = get( 'rest-customer2@example.com', 'customer2pw', '/api/v1/organizations/' + organization2.id.to_s ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # packages - request = get( 'rest-admin@example.com', 'adminpw', '/api/v1/packages' ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Hash) - assert( request[:data]['packages'] ) - - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/packages' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/packages' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - # settings - request = get( 'rest-admin@example.com', 'adminpw', '/api/v1/settings' ) - assert_equal( request[:response].code, '200' ) - assert_equal( request[:data].class, Array) - assert( request[:data][0] ) - - request = get( 'rest-agent@example.com', 'agentpw', '/api/v1/settings' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - request = get( 'rest-customer1@example.com', 'customer1pw', '/api/v1/settings' ) - assert_equal( request[:response].code, '401' ) - assert_equal( request[:data].class, NilClass) - - end - def get(user, pw, url) - - response = UserAgent.get( - "#{ENV['BROWSER_URL']}#{url}", - {}, - { - json: true, - user: user, - password: pw, - } - ) - #puts 'URL: ' + url - #puts response.code.to_s - #puts response.body.to_s - { data: response.data, response: response } - end -end From e21966aede657f29806f329eeb2535c39f0f5915 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 15 Aug 2015 10:11:02 +0200 Subject: [PATCH 04/35] Fixed typo. --- app/models/translation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/translation.rb b/app/models/translation.rb index 0d1b94693..0f10fffff 100644 --- a/app/models/translation.rb +++ b/app/models/translation.rb @@ -17,7 +17,7 @@ load translations from online def self.load locales = Locale.where(active: true) if Rails.env.test? - locales = Locale.where(active: true, name: ['en-us', 'de-de']) + locales = Locale.where(active: true, locale: ['en-us', 'de-de']) end locales.each {|locale| url = "https://i18n.zammad.com/api/v1/translations/#{locale.locale}" From 67c708daa5610286138582a23ac99284e6cabc42 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sat, 15 Aug 2015 11:21:53 +0200 Subject: [PATCH 05/35] Upgrade to new rails 4 behaviour. --- config/initializers/secret_token.rb | 7 ------- config/secrets.yml | 8 ++++++++ 2 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 config/initializers/secret_token.rb create mode 100644 config/secrets.yml diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb deleted file mode 100644 index 36b418e58..000000000 --- a/config/initializers/secret_token.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -Zammad::Application.config.secret_token = '7e2713d027d0cd980171f483a37bff6304f7e994f07f337b6130fec20c2e9c8f8093a9fc70128f13fe9d006f7f785064c8e612e92c6171cb35ba675b626f633d' diff --git a/config/secrets.yml b/config/secrets.yml new file mode 100644 index 000000000..39df6a8fa --- /dev/null +++ b/config/secrets.yml @@ -0,0 +1,8 @@ +development: + secret_key_base: secret_key_base_is_not_used + +test: + secret_key_base: secret_key_base_is_not_used + +production: + secret_key_base: secret_key_base_is_not_used From 4655039cd104c099ebaf05620dd1cbafd45dc657 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sun, 16 Aug 2015 01:27:11 +0200 Subject: [PATCH 06/35] Merged /api/v1/search and /api/v1/search/object controller methods. Added controller tests for permission handling. --- .../app/controllers/navigation.js.coffee | 54 ++- .../app/views/navigation/result.jst.eco | 7 +- app/controllers/search_controller.rb | 97 +--- config/routes/search.rb | 4 +- test/controllers/search_controller_test.rb | 425 ++++++++++++++++++ 5 files changed, 467 insertions(+), 120 deletions(-) create mode 100644 test/controllers/search_controller_test.rb diff --git a/app/assets/javascripts/app/controllers/navigation.js.coffee b/app/assets/javascripts/app/controllers/navigation.js.coffee index 56fc6dac4..c0a99af81 100644 --- a/app/assets/javascripts/app/controllers/navigation.js.coffee +++ b/app/assets/javascripts/app/controllers/navigation.js.coffee @@ -142,15 +142,15 @@ class App.Navigation extends App.ControllerWidgetPermanent searchFunction = => # use cache for search result - if @searchResultCache[@term] - @renderResult( @searchResultCache[@term] ) + if @searchResultCache[@query] + @renderResult( @searchResultCache[@query] ) App.Ajax.request( id: 'search' type: 'GET' url: @apiPath + '/search' data: - term: @term + query: @query processData: true, success: (data, status, xhr) => @@ -158,25 +158,21 @@ class App.Navigation extends App.ControllerWidgetPermanent App.Collection.loadAssets( data.assets ) # cache search result - @searchResultCache[@term] = data.result + @searchResultCache[@query] = data.result - result = data.result - for area in result - if area.name is 'Ticket' - area.result = [] - for id in area.ids - ticket = App.Ticket.find( id ) - area.result.push ticket.searchResultAttributes() - else if area.name is 'User' - area.result = [] - for id in area.ids - user = App.User.find( id ) - area.result.push user.searchResultAttributes() - else if area.name is 'Organization' - area.result = [] - for id in area.ids - organization = App.Organization.find( id ) - area.result.push organization.searchResultAttributes() + result = {} + for item in data.result + if App[item.type] && App[item.type].find + if !result[item.type] + result[item.type] = [] + item_object = App[item.type].find(item.id) + if item_object.searchResultAttributes + item_object_search_attributes = item_object.searchResultAttributes() + result[item.type].push item_object_search_attributes + else + @log 'error', "No such model #{item.type.toLocaleLowerCase()}.searchResultAttributes()" + else + @log 'error', "No such model App.#{item.type}" @renderResult(result) @@ -219,9 +215,9 @@ class App.Navigation extends App.ControllerWidgetPermanent removePopovers() # check if search is needed - term = @$('#global-search').val().trim() - return if !term - @term = term + query = @$('#global-search').val().trim() + return if !query + @query = query @delay( searchFunction, 220, 'search' ) ) @@ -239,11 +235,11 @@ class App.Navigation extends App.ControllerWidgetPermanent return # on other keys, show result - term = @$('#global-search').val().trim() - return if !term - return if term is @term - @term = term - @$('.search').toggleClass('filled', !!@term) + query = @$('#global-search').val().trim() + return if !query + return if query is @query + @query = query + @$('.search').toggleClass('filled', !!@query) @delay( searchFunction, 200, 'search' ) ) diff --git a/app/assets/javascripts/app/views/navigation/result.jst.eco b/app/assets/javascripts/app/views/navigation/result.jst.eco index 3c79809d4..ed0d6404d 100644 --- a/app/assets/javascripts/app/views/navigation/result.jst.eco +++ b/app/assets/javascripts/app/views/navigation/result.jst.eco @@ -1,6 +1,7 @@ -<% for area, i in @result: %> - <% if i > 0: %>
  • <% end %> - <% for item in area.result: %> +<% for area, items of @result: %> + <% if done && items.length > 0: %>
  • <% end %> + <% done = true %> + <% for item in items: %>
  • <%= item.title %> -
    +
    <%- @humanTime(item.created_at) %>
    diff --git a/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco b/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco index 9fbdd6f85..129e2bde7 100644 --- a/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco +++ b/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco @@ -9,7 +9,7 @@
    <%= ticket.title %> -
    +
    <%- @humanTime(ticket.created_at) %>
  • <% end %> From 7adc2ea9193ca24dff1a1f255c956e255f25e0b1 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Sun, 16 Aug 2015 17:46:57 +0200 Subject: [PATCH 15/35] Improved humanTime helper method. --- app/assets/javascripts/app/index.js.coffee | 21 +++++++++---------- .../views/dashboard/activity_stream.jst.eco | 2 +- .../app/views/generic/history.jst.eco | 2 +- .../javascripts/app/views/link/info.jst.eco | 2 +- .../javascripts/app/views/session.jst.eco | 4 ++-- .../views/ticket_zoom/article_view.jst.eco | 2 +- .../app/views/ticket_zoom/meta.jst.eco | 2 +- .../online_notification_content.jst.eco | 2 +- .../views/widget/ticket_stats_list.jst.eco | 2 +- public/assets/tests/model-ui.js | 4 ++-- public/assets/tests/table.js | 18 ++++++++-------- 11 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/assets/javascripts/app/index.js.coffee b/app/assets/javascripts/app/index.js.coffee index c4046e373..c7ea196c3 100644 --- a/app/assets/javascripts/app/index.js.coffee +++ b/app/assets/javascripts/app/index.js.coffee @@ -108,12 +108,11 @@ class App extends Spine.Controller else if attribute_config.tag is 'datetime' isHtmlEscape = true timestamp = App.i18n.translateTimestamp(result) - escalation = undefined - if attribute_config.class is 'escalation' - escalation + escalation = false + if attribute_config.class && attribute_config.class.match 'escalation' + escalation = true humanTime = App.PrettyDate.humanTime(result, escalation) - result = "#{humanTime}" - #result = App.i18n.translateTimestamp(result) + result = "" if !isHtmlEscape && typeof result is 'string' result = App.Utils.htmlEscape(result) @@ -222,12 +221,12 @@ class App extends Spine.Controller App.Utils.humanFileSize(size) # define pretty/human time helper - params.humanTime = ( time, escalation ) -> - App.PrettyDate.humanTime(time, escalation) - - # define pretty/human time helper - params.timestamp = ( time ) -> - App.i18n.translateTimestamp(time) + params.humanTime = ( time, escalation = false, cssClass = '') -> + timestamp = App.i18n.translateTimestamp(time) + if escalation + cssClass += ' escalation' + humanTime = App.PrettyDate.humanTime(time, escalation) + "" # define template JST["app/views/#{name}"](params) diff --git a/app/assets/javascripts/app/views/dashboard/activity_stream.jst.eco b/app/assets/javascripts/app/views/dashboard/activity_stream.jst.eco index af16e255f..825d6e473 100644 --- a/app/assets/javascripts/app/views/dashboard/activity_stream.jst.eco +++ b/app/assets/javascripts/app/views/dashboard/activity_stream.jst.eco @@ -5,7 +5,7 @@ <%= @item.created_by.displayName() %> <%- @T( @item.type ) %> <%- @T( @item.object_name ) %><% if @item.title: %> (<%= @item.title %>)<% end %> - <%- @humanTime(@item.created_at) %> + <%- @humanTime(@item.created_at, false, 'activity-time') %> diff --git a/app/assets/javascripts/app/views/generic/history.jst.eco b/app/assets/javascripts/app/views/generic/history.jst.eco index 281923ec8..d074af13d 100644 --- a/app/assets/javascripts/app/views/generic/history.jst.eco +++ b/app/assets/javascripts/app/views/generic/history.jst.eco @@ -5,7 +5,7 @@ <% for item in @items: %> <%= item.created_by.displayName() %> - - <%- @humanTime(item.created_at) %> + <%- @humanTime(item.created_at) %>
      <% for content in item.records: %>
    • <%- content %>
    • diff --git a/app/assets/javascripts/app/views/link/info.jst.eco b/app/assets/javascripts/app/views/link/info.jst.eco index dd63ebb43..e91a4b8c3 100644 --- a/app/assets/javascripts/app/views/link/info.jst.eco +++ b/app/assets/javascripts/app/views/link/info.jst.eco @@ -10,7 +10,7 @@
      <%= item.title %> -
      <%- @humanTime(item.created_at) %>
      + <%- @humanTime(item.created_at) %>
      diff --git a/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco b/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco index 129e2bde7..c1f4674ec 100644 --- a/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco +++ b/app/assets/javascripts/app/views/widget/ticket_stats_list.jst.eco @@ -9,7 +9,7 @@
      <%= ticket.title %> -
      <%- @humanTime(ticket.created_at) %>
      + <%- @humanTime(ticket.created_at, false, 'time') %>
      <% end %> diff --git a/public/assets/tests/model-ui.js b/public/assets/tests/model-ui.js index bf6dafc76..c20742700 100644 --- a/public/assets/tests/model-ui.js +++ b/public/assets/tests/model-ui.js @@ -44,7 +44,7 @@ test( "model ui basic tests", function() { equal( App.viewPrint( ticket, 'state' ), 'open') equal( App.viewPrint( ticket, 'state_id' ), 'open') equal( App.viewPrint( ticket, 'not_existing' ), '-') - equal( App.viewPrint( ticket, 'updated_at' ), "?") + equal( App.viewPrint( ticket, 'updated_at' ), '') equal( App.viewPrint( ticket, 'date' ), '02/07/2015') equal( App.viewPrint( ticket, 'textarea' ), '
      some new
      line
      ') @@ -55,7 +55,7 @@ test( "model ui basic tests", function() { equal( App.viewPrint( ticket, 'state' ), 'offen') equal( App.viewPrint( ticket, 'state_id' ), 'offen') equal( App.viewPrint( ticket, 'not_existing' ), '-') - equal( App.viewPrint( ticket, 'updated_at' ), "?") + equal( App.viewPrint( ticket, 'updated_at' ), '') equal( App.viewPrint( ticket, 'date' ), '07.02.2015') equal( App.viewPrint( ticket, 'textarea' ), '
      some new
      line
      ') diff --git a/public/assets/tests/table.js b/public/assets/tests/table.js index 18a199462..3975f053a 100644 --- a/public/assets/tests/table.js +++ b/public/assets/tests/table.js @@ -91,11 +91,11 @@ test( "table test", function() { equal( el.find('table > thead > tr > th:nth-child(3)').text().trim(), 'Aktiv', 'check header') equal( el.find('tbody > tr:nth-child(1) > td').length, 3, 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:first').text().trim(), '1 niedrig', 'check row 1') - equal( el.find('tbody > tr:nth-child(1) > td:nth-child(2)').text().trim(), '?', 'check row 1') + equal( el.find('tbody > tr:nth-child(1) > td:nth-child(2)').text().trim(), '10.06.2014', 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:nth-child(3)').text().trim(), 'true', 'check row 1') equal( el.find('tbody > tr:nth-child(2) > td').length, 3, 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:first').text().trim(), '2 normal', 'check row 2') - equal( el.find('tbody > tr:nth-child(2) > td:nth-child(2)').text().trim(), '?', 'check row 2') + equal( el.find('tbody > tr:nth-child(2) > td:nth-child(2)').text().trim(), '10.06.2014', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(3)').text().trim(), 'false', 'check row 2') $('#table').append('

      table simple II

      ') @@ -114,11 +114,11 @@ test( "table test", function() { equal( el.find('table > thead > tr > th:nth-child(3)').text().trim(), 'Aktiv', 'check header') equal( el.find('tbody > tr:nth-child(1) > td').length, 3, 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:first').text().trim(), '2 normal', 'check row 1') - equal( el.find('tbody > tr:nth-child(1) > td:nth-child(2)').text().trim(), '?', 'check row 1') + equal( el.find('tbody > tr:nth-child(1) > td:nth-child(2)').text().trim(), '10.06.2014', 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:nth-child(3)').text().trim(), 'false', 'check row 1') equal( el.find('tbody > tr:nth-child(2) > td').length, 3, 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:first').text().trim(), '1 niedrig', 'check row 2') - equal( el.find('tbody > tr:nth-child(2) > td:nth-child(2)').text().trim(), '?', 'check row 2') + equal( el.find('tbody > tr:nth-child(2) > td:nth-child(2)').text().trim(), '10.06.2014', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(3)').text().trim(), 'true', 'check row 2') $('#table').append('

      table simple III

      ') @@ -257,7 +257,7 @@ test( "table test", function() { equal( el.find('tbody > tr:nth-child(1) > td:nth-child(6)').text().trim(), '2 normal', 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:nth-child(7)').text().trim(), 'group 2', 'check row 1') equal( el.find('tbody > tr:nth-child(1) > td:nth-child(8)').text().trim(), 'neu', 'check row 1') - equal( el.find('tbody > tr:nth-child(1) > td:nth-child(9)').text().trim(), '?', 'check row 1') + equal( el.find('tbody > tr:nth-child(1) > td:nth-child(9)').text().trim(), '11.07.2014', 'check row 1') equal( el.find('tbody > tr:nth-child(2) > td').length, 9, 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(1) input').val(), '2', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(1) input').prop('checked'), '', 'check row 2') @@ -269,7 +269,7 @@ test( "table test", function() { equal( el.find('tbody > tr:nth-child(2) > td:nth-child(6)').text().trim(), '1 niedrig', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(7)').text().trim(), 'group 1', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(8)').text().trim(), 'offen', 'check row 2') - equal( el.find('tbody > tr:nth-child(2) > td:nth-child(9)').text().trim(), '?', 'check row 2') + equal( el.find('tbody > tr:nth-child(2) > td:nth-child(9)').text().trim(), '10.06.2014', 'check row 2') equal( el.find('tbody > tr:nth-child(3) > td').length, 9, 'check row 3') equal( el.find('tbody > tr:nth-child(3) > td:nth-child(1) input').val(), '1', 'check row 3') equal( el.find('tbody > tr:nth-child(3) > td:nth-child(1) input').prop('checked'), '', 'check row 3') @@ -281,7 +281,7 @@ test( "table test", function() { equal( el.find('tbody > tr:nth-child(3) > td:nth-child(6)').text().trim(), '1 niedrig', 'check row 3') equal( el.find('tbody > tr:nth-child(3) > td:nth-child(7)').text().trim(), 'group 2', 'check row 3') equal( el.find('tbody > tr:nth-child(3) > td:nth-child(8)').text().trim(), 'neu', 'check row 3') - equal( el.find('tbody > tr:nth-child(3) > td:nth-child(9)').text().trim(), '?', 'check row 3') + equal( el.find('tbody > tr:nth-child(3) > td:nth-child(9)').text().trim(), '10.06.2014', 'check row 3') el.find('input[name="bulk_all"]').click() equal( el.find('tbody > tr:nth-child(1) > td:nth-child(1) input').prop('checked'), true, 'check row 1') @@ -330,7 +330,7 @@ test( "table test", function() { equal( el.find('tbody > tr:nth-child(2) > td:nth-child(5)').text().trim(), '-', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(6)').text().trim(), '1 niedrig', 'check row 2') equal( el.find('tbody > tr:nth-child(2) > td:nth-child(7)').text().trim(), 'offen', 'check row 2') - equal( el.find('tbody > tr:nth-child(2) > td:nth-child(8)').text().trim(), '?', 'check row 2') + equal( el.find('tbody > tr:nth-child(2) > td:nth-child(8)').text().trim(), '10.06.2014', 'check row 2') equal( el.find('tbody > tr:nth-child(3) > td').length, 1, 'check row 3') equal( el.find('tbody > tr:nth-child(3) > td:nth-child(1)').text().trim(), 'group 2', 'check row 4') equal( el.find('tbody > tr:nth-child(4) > td').length, 8, 'check row 4') @@ -343,7 +343,7 @@ test( "table test", function() { equal( el.find('tbody > tr:nth-child(4) > td:nth-child(5)').text().trim(), '-', 'check row 2') equal( el.find('tbody > tr:nth-child(4) > td:nth-child(6)').text().trim(), '2 normal', 'check row 4') equal( el.find('tbody > tr:nth-child(4) > td:nth-child(7)').text().trim(), 'neu', 'check row 4') - equal( el.find('tbody > tr:nth-child(4) > td:nth-child(8)').text().trim(), '?', 'check row 4') + equal( el.find('tbody > tr:nth-child(4) > td:nth-child(8)').text().trim(), '11.07.2014', 'check row 4') el.find('input[name="bulk"]:eq(1)').click() equal( el.find('tbody > tr:nth-child(2) > td:nth-child(1) input').prop('checked'), '', 'check row 2') From 6366629205bb841707e5a5ddd1984a94c3e4d45f Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 08:39:23 +0200 Subject: [PATCH 16/35] Improved humanTime helper method. --- app/assets/javascripts/app/index.js.coffee | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/app/index.js.coffee b/app/assets/javascripts/app/index.js.coffee index c7ea196c3..602831265 100644 --- a/app/assets/javascripts/app/index.js.coffee +++ b/app/assets/javascripts/app/index.js.coffee @@ -109,10 +109,11 @@ class App extends Spine.Controller isHtmlEscape = true timestamp = App.i18n.translateTimestamp(result) escalation = false - if attribute_config.class && attribute_config.class.match 'escalation' + cssClass = attribute_config.class || '' + if cssClass.match 'escalation' escalation = true humanTime = App.PrettyDate.humanTime(result, escalation) - result = "" + result = "" if !isHtmlEscape && typeof result is 'string' result = App.Utils.htmlEscape(result) From 94c49062640065f581c28c9b128ef265db77fbc3 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 11:00:33 +0200 Subject: [PATCH 17/35] Fixed time zone issue. --- public/assets/tests/model-ui.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/assets/tests/model-ui.js b/public/assets/tests/model-ui.js index c20742700..8f5a7d99d 100644 --- a/public/assets/tests/model-ui.js +++ b/public/assets/tests/model-ui.js @@ -44,7 +44,7 @@ test( "model ui basic tests", function() { equal( App.viewPrint( ticket, 'state' ), 'open') equal( App.viewPrint( ticket, 'state_id' ), 'open') equal( App.viewPrint( ticket, 'not_existing' ), '-') - equal( App.viewPrint( ticket, 'updated_at' ), '') + equal( App.viewPrint( ticket, 'updated_at' ), '') equal( App.viewPrint( ticket, 'date' ), '02/07/2015') equal( App.viewPrint( ticket, 'textarea' ), '
      some new
      line
      ') @@ -55,7 +55,7 @@ test( "model ui basic tests", function() { equal( App.viewPrint( ticket, 'state' ), 'offen') equal( App.viewPrint( ticket, 'state_id' ), 'offen') equal( App.viewPrint( ticket, 'not_existing' ), '-') - equal( App.viewPrint( ticket, 'updated_at' ), '') + equal( App.viewPrint( ticket, 'updated_at' ), '') equal( App.viewPrint( ticket, 'date' ), '07.02.2015') equal( App.viewPrint( ticket, 'textarea' ), '
      some new
      line
      ') From 7d17849214cad355c2d5c77f72386ceabf37b8fc Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 15:25:41 +0200 Subject: [PATCH 18/35] Init version of device logging. --- Gemfile | 1 + .../controllers/_profile/devices.js.coffee | 51 ++++++++++++++ .../app/views/profile/devices.jst.eco | 30 ++++++++ app/controllers/application_controller.rb | 22 +++++- app/models/user_device.rb | 69 +++++++++++++++++++ config/routes/user_devices.rb | 8 +++ .../20150817000001_create_user_devices.rb | 22 ++++++ lib/models.rb | 3 + 8 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/app/controllers/_profile/devices.js.coffee create mode 100644 app/assets/javascripts/app/views/profile/devices.jst.eco create mode 100644 app/models/user_device.rb create mode 100644 config/routes/user_devices.rb create mode 100644 db/migrate/20150817000001_create_user_devices.rb diff --git a/Gemfile b/Gemfile index 537856bf0..2474d3fcd 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,7 @@ gem 'net-ldap' gem 'writeexcel' gem 'icalendar' +gem 'browser' # event machine gem 'eventmachine' diff --git a/app/assets/javascripts/app/controllers/_profile/devices.js.coffee b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee new file mode 100644 index 000000000..b5fcba939 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee @@ -0,0 +1,51 @@ +class Index extends App.Controller + events: + 'click [data-type=delete]': 'delete' + + constructor: -> + super + return if !@authenticate() + @title 'Devices', true + + @load() + @interval( + => + @load() + 62000 + ) + + # fetch data, render view + load: => + @ajax( + id: 'user_devices' + type: 'GET' + url: @apiPath + '/user_devices' + success: (data) => + @render(data) + ) + + render: (data) => + @html App.view('profile/devices')( devices: data ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('a').data('device-id') + console.log('ID', id) + # get data + @ajax( + id: 'user_devices_delete' + type: 'DELETE' + url: "#{@apiPath}/user_devices/#{id}" + processData: true + success: @load + error: @error + ) + + error: (xhr, status, error) => + data = JSON.parse( xhr.responseText ) + @notify( + type: 'error' + msg: App.i18n.translateContent( data.message ) + ) + +App.Config.set( 'Devices', { prio: 3100, name: 'Devices', parent: '#profile', target: '#profile/devices', controller: Index }, 'NavBarProfile' ) diff --git a/app/assets/javascripts/app/views/profile/devices.jst.eco b/app/assets/javascripts/app/views/profile/devices.jst.eco new file mode 100644 index 000000000..c99ad563d --- /dev/null +++ b/app/assets/javascripts/app/views/profile/devices.jst.eco @@ -0,0 +1,30 @@ + + +
      + +

      <%- @T('All computers and browsers that have access to your Zammad appear here.') %>

      + + + + + + + + + + + + <% for device in @devices: %> + + + + + + + <% end %> + +
      <%- @T('Name') %><%- @T('Location') %><%- @T('Most recent activity') %><%- @T('Remove') %>
      <%= device.name %><%= device.location %><%- @humanTime(device.updated_at) %>
      + +
      \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 691b1197f..26b5029df 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,7 +14,7 @@ class ApplicationController < ActionController::Base :model_index_render skip_before_action :verify_authenticity_token - before_action :set_user, :session_update + before_action :set_user, :session_update, :check_user_device before_action :cors_preflight_check after_action :set_access_control_headers @@ -95,6 +95,26 @@ class ApplicationController < ActionController::Base session[:user_agent] = request.env['HTTP_USER_AGENT'] end + # check user device + def check_user_device + + # only if user_id exists + return if !session[:user_id] + + # only if write action + return if request.method == 'GET' || request.method == 'OPTIONS' + + # only update if needed + return if session[:check_user_device_at] && session[:check_user_device_at] < Time.zone.now - 10.minutes + session[:check_user_device_at] = Time.zone.now + + UserDevice.add( + session[:user_agent], + session[:remote_id], + session[:user_id], + ) + end + def authentication_check_only(auth_param) logger.debug 'authentication_check' diff --git a/app/models/user_device.rb b/app/models/user_device.rb new file mode 100644 index 000000000..6c3ea4d97 --- /dev/null +++ b/app/models/user_device.rb @@ -0,0 +1,69 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class UserDevice < ApplicationModel + store :device_details + store :location_details + validates :name, presence: true + +=begin + +store device for user + + UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '172.0.0.1', + user.id, + ) + +=end + + def self.add(user_agent, ip, user_id) + + # get browser details + browser = Browser.new(:ua => user_agent, :accept_language => 'en-us') + browser = { + plattform: browser.platform.to_s.camelize, + name: browser.name, + version: browser.version, + full_version: browser.full_version, + } + + # generate device name + name = browser[:plattform] || '' + if browser[:name] + if name + name += ', ' + end + name += browser[:name] + end + + # get location info + location = Service::GeoIp.location(ip) + country = location['country_name'] + + # check if exists + exists = self.find_by( + :user_id => user_id, + os: browser[:plattform], + browser: browser[:name], + country: country, + ) + + if exists + exists.touch + return exists + end + + # create new device + self.create( + user_id: user_id, + name: name, + os: browser[:plattform], + browser: browser[:name], + country: country, + device_details: browser, + location_details: location, + ) + end + +end diff --git a/config/routes/user_devices.rb b/config/routes/user_devices.rb new file mode 100644 index 000000000..ba44cdbba --- /dev/null +++ b/config/routes/user_devices.rb @@ -0,0 +1,8 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + # jobs + match api_path + '/user_devices', to: 'user_devices#index', via: :get + match api_path + '/user_devices/:id', to: 'user_devices#destroy', via: :delete + +end diff --git a/db/migrate/20150817000001_create_user_devices.rb b/db/migrate/20150817000001_create_user_devices.rb new file mode 100644 index 000000000..013fb1d08 --- /dev/null +++ b/db/migrate/20150817000001_create_user_devices.rb @@ -0,0 +1,22 @@ + +class CreateUserDevices < ActiveRecord::Migration + def up + create_table :user_devices do |t| + t.references :user, null: false + t.string :name, limit: 250, null: false + t.string :os, limit: 150, null: true + t.string :browser, limit: 250, null: true + t.string :country, limit: 150, null: true + t.string :device_details, limit: 2500, null: true + t.string :location_details, limit: 2500, null: true + t.timestamps + end + add_index :user_devices, [:user_id] + add_index :user_devices, [:os, :browser, :country] + add_index :user_devices, [:updated_at] + end + + def down + drop_table :user_devices + end +end diff --git a/lib/models.rb b/lib/models.rb index 4158450e3..c798a9aaa 100644 --- a/lib/models.rb +++ b/lib/models.rb @@ -35,6 +35,9 @@ returns model_class = load_adapter(entry) next if !model_class next if !model_class.respond_to? :new + next if !model_class.respond_to? :table_name + table_name = model_class.table_name # handle models where not table exists, pending migrations + next if !ActiveRecord::Base.connection.tables.include?(table_name) model_object = model_class.new next if !model_object.respond_to? :attributes all[model_class] = {} From 0917cec85521539d4a7e8557217dc4162f7df7ad Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 16:12:40 +0200 Subject: [PATCH 19/35] Init version of device logging. --- app/controllers/user_devices_controller.rb | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/controllers/user_devices_controller.rb diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb new file mode 100644 index 000000000..480e9cae4 --- /dev/null +++ b/app/controllers/user_devices_controller.rb @@ -0,0 +1,27 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class UserDevicesController < ApplicationController + before_action :authentication_check + + def index + devices = UserDevice.where(user_id: current_user.id).order('updated_at DESC') + devices_full = [] + devices.each {|device| + attributes = device.attributes + if device.location_details['city'] + attributes['country'] += ", #{device.location_details['city']}" + end + attributes.delete('created_at') + attributes.delete('device_details') + attributes.delete('location_details') + devices_full.push attributes + } + model_index_render_result(devices_full) + end + + def destroy + UserDevice.where(user_id: current_user.id, id: params[:id]).destroy_all + render json: {}, status: :ok + end + +end From 6d7124a1690a751f409d7bb50923e99bda6de2f0 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 17:16:17 +0200 Subject: [PATCH 20/35] Improved device logging. --- app/controllers/user_devices_controller.rb | 2 +- app/models/user_device.rb | 4 ++-- db/migrate/20150817000002_update_user_devices.rb | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20150817000002_update_user_devices.rb diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb index 480e9cae4..53870dbda 100644 --- a/app/controllers/user_devices_controller.rb +++ b/app/controllers/user_devices_controller.rb @@ -9,7 +9,7 @@ class UserDevicesController < ApplicationController devices.each {|device| attributes = device.attributes if device.location_details['city'] - attributes['country'] += ", #{device.location_details['city']}" + attributes['location'] += ", #{device.location_details['city']}" end attributes.delete('created_at') attributes.delete('device_details') diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 6c3ea4d97..0c6b2d16d 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -46,7 +46,7 @@ store device for user :user_id => user_id, os: browser[:plattform], browser: browser[:name], - country: country, + location: country, ) if exists @@ -60,7 +60,7 @@ store device for user name: name, os: browser[:plattform], browser: browser[:name], - country: country, + location: country, device_details: browser, location_details: location, ) diff --git a/db/migrate/20150817000002_update_user_devices.rb b/db/migrate/20150817000002_update_user_devices.rb new file mode 100644 index 000000000..05eda7d22 --- /dev/null +++ b/db/migrate/20150817000002_update_user_devices.rb @@ -0,0 +1,9 @@ +class UpdateUserDevices < ActiveRecord::Migration + def up + add_column :user_devices, :location, :string, limit: 150, null: true + remove_column :user_devices, :country + add_index :user_devices, [:os, :browser, :location] + UserDevice.reset_column_information + end + +end From 4b43ac7338aec3f01da0d442664d53bc4dc8ab4b Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 17:17:21 +0200 Subject: [PATCH 21/35] Improved device logging. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 26b5029df..474524bbd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -105,7 +105,7 @@ class ApplicationController < ActionController::Base return if request.method == 'GET' || request.method == 'OPTIONS' # only update if needed - return if session[:check_user_device_at] && session[:check_user_device_at] < Time.zone.now - 10.minutes + return if session[:check_user_device_at] && session[:check_user_device_at] < Time.zone.now - 5.minutes session[:check_user_device_at] = Time.zone.now UserDevice.add( From 06f02b22984bb18ff4d0d270d01b7c362033adc8 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 17:26:13 +0200 Subject: [PATCH 22/35] Fixed updateding device logging. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 474524bbd..d9ef7d6a6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -105,7 +105,7 @@ class ApplicationController < ActionController::Base return if request.method == 'GET' || request.method == 'OPTIONS' # only update if needed - return if session[:check_user_device_at] && session[:check_user_device_at] < Time.zone.now - 5.minutes + return if session[:check_user_device_at] && session[:check_user_device_at] > Time.zone.now - 5.minutes session[:check_user_device_at] = Time.zone.now UserDevice.add( From d5421e38ee7fe8577a611d0f240a1032266fb0ed Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 17:36:52 +0200 Subject: [PATCH 23/35] Improved error handling. --- .../javascripts/app/controllers/_profile/devices.js.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/app/controllers/_profile/devices.js.coffee b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee index b5fcba939..830397831 100644 --- a/app/assets/javascripts/app/controllers/_profile/devices.js.coffee +++ b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee @@ -29,7 +29,9 @@ class Index extends App.Controller delete: (e) => e.preventDefault() - id = $(e.target).closest('a').data('device-id') + id = $(e.target).data('device-id') + if !id + id = $(e.target).closest('a').data('device-id') console.log('ID', id) # get data @ajax( From b27e813ddc2b4474b21e97b8de04a048caaed330 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 17:51:42 +0200 Subject: [PATCH 24/35] Prevent clickable tags. --- .../javascripts/app/controllers/_profile/devices.js.coffee | 7 ++----- app/assets/stylesheets/zammad.css.scss | 7 ++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/app/controllers/_profile/devices.js.coffee b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee index 830397831..4cd832e12 100644 --- a/app/assets/javascripts/app/controllers/_profile/devices.js.coffee +++ b/app/assets/javascripts/app/controllers/_profile/devices.js.coffee @@ -29,11 +29,8 @@ class Index extends App.Controller delete: (e) => e.preventDefault() - id = $(e.target).data('device-id') - if !id - id = $(e.target).closest('a').data('device-id') - console.log('ID', id) - # get data + id = $(e.target).closest('a').data('device-id') + @ajax( id: 'user_devices_delete' type: 'DELETE' diff --git a/app/assets/stylesheets/zammad.css.scss b/app/assets/stylesheets/zammad.css.scss index 99662b88c..018af53e5 100644 --- a/app/assets/stylesheets/zammad.css.scss +++ b/app/assets/stylesheets/zammad.css.scss @@ -27,10 +27,15 @@ body { flex-direction: column; } +/* prevent clickable */ +use { + pointer-events: none; +} + p { margin: 14px 0; color: hsl(60,1%,34%); - + &.subtle { color: hsl(60,1%,74%); } From 6fe9276ba70e1da83bdb2da6fcc534c2d7e8a6b0 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 18:13:27 +0200 Subject: [PATCH 25/35] Allow also disable of tags. --- app/assets/stylesheets/zammad.css.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/zammad.css.scss b/app/assets/stylesheets/zammad.css.scss index 018af53e5..cc1120782 100644 --- a/app/assets/stylesheets/zammad.css.scss +++ b/app/assets/stylesheets/zammad.css.scss @@ -52,6 +52,13 @@ p { a { outline: none !important; @extend .u-highlight; + + &.is-disabled, + &[disabled] { + pointer-events: none; + cursor: not-allowed; + opacity: .33; + } } a.create { From f2ef65d67d646a8656a0a442e93b9769181086a4 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Mon, 17 Aug 2015 18:14:44 +0200 Subject: [PATCH 26/35] Improved device logging, do not delete current used device. Delete also sessions of device. --- .../app/views/profile/devices.jst.eco | 2 +- app/controllers/application_controller.rb | 8 +++++++- app/controllers/user_devices_controller.rb | 18 +++++++++++++++++- app/models/user_device.rb | 2 +- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/app/views/profile/devices.jst.eco b/app/assets/javascripts/app/views/profile/devices.jst.eco index c99ad563d..347b3ffc5 100644 --- a/app/assets/javascripts/app/views/profile/devices.jst.eco +++ b/app/assets/javascripts/app/views/profile/devices.jst.eco @@ -21,7 +21,7 @@ <%= device.name %> <%= device.location %> <%- @humanTime(device.updated_at) %> - + disabled<% end %>> <% end %> diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d9ef7d6a6..7785c8063 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -98,6 +98,9 @@ class ApplicationController < ActionController::Base # check user device def check_user_device + # return if we are in switch to user mode + return if session[:switched_from_user_id] + # only if user_id exists return if !session[:user_id] @@ -108,11 +111,14 @@ class ApplicationController < ActionController::Base return if session[:check_user_device_at] && session[:check_user_device_at] > Time.zone.now - 5.minutes session[:check_user_device_at] = Time.zone.now - UserDevice.add( + user_device = UserDevice.add( session[:user_agent], session[:remote_id], session[:user_id], ) + if user_device.id != session[:check_user_device_id] + session[:check_user_device_id] = user_device.id + end end def authentication_check_only(auth_param) diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb index 53870dbda..e80db5115 100644 --- a/app/controllers/user_devices_controller.rb +++ b/app/controllers/user_devices_controller.rb @@ -14,13 +14,29 @@ class UserDevicesController < ApplicationController attributes.delete('created_at') attributes.delete('device_details') attributes.delete('location_details') + + if session[:check_user_device_id] == device.id + attributes['current'] = true + end devices_full.push attributes } model_index_render_result(devices_full) end def destroy - UserDevice.where(user_id: current_user.id, id: params[:id]).destroy_all + # find device + user_device = UserDevice.find_by(user_id: current_user.id, id: params[:id]) + + # delete device and session's + if user_device + SessionHelper.list.each {|session| + next if !session.data['user_id'] + next if !session.data['check_user_device_id'] + next if session.data['check_user_device_id'] != user_device.id + SessionHelper.destroy( session.id ) + } + user_device.destroy + end render json: {}, status: :ok end diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 0c6b2d16d..1e9b5649a 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -9,7 +9,7 @@ class UserDevice < ApplicationModel store device for user - UserDevice.add( + user_device = UserDevice.add( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', '172.0.0.1', user.id, From 2760137eef56b7cee5d72bcac5879f14951d47e1 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 18 Aug 2015 10:50:12 +0200 Subject: [PATCH 27/35] Removed not used logon_session feature. Added web socket authentication. Prepared browser finger print. --- .../app/lib/app_post/browser.coffee | 26 ++++++++++++++++++ .../app/lib/app_post/websocket.js.coffee | 4 +-- app/controllers/application_controller.rb | 23 +++------------- app/controllers/sessions_controller.rb | 26 +++--------------- lib/session_helper.rb | 4 +-- script/websocket-server.rb | 27 ++++++++++++++++--- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/app/assets/javascripts/app/lib/app_post/browser.coffee b/app/assets/javascripts/app/lib/app_post/browser.coffee index d82f5a9a5..8d3013879 100644 --- a/app/assets/javascripts/app/lib/app_post/browser.coffee +++ b/app/assets/javascripts/app/lib/app_post/browser.coffee @@ -47,6 +47,32 @@ class App.Browser # allow browser true + @fingerprint: -> + localStorage = window['localStorage'] + + # read from local storage + if localStorage + fingerprint = localStorage.getItem('fingerprint') + return fingerprint if fingerprint + + # detect fingerprint + data = @detection() + resolution = "#{window.screen.availWidth}x#{window.screen.availHeight}/#{window.screen.pixelDepth}" + timezone = new Date().toString().match(/\s\(.+?\)$/) + hashCode = (s) -> + s.split('').reduce( + (a,b) -> + a=((a<<5)-a)+b.charCodeAt(0) + a&a + 0 + ) + fingerprint = hashCode("#{data.browser.name}#{data.browser.major}#{data.os}#{resolution}#{timezone}") + + # write to local storage + if localStorage + localStorage.setItem('fingerprint', fingerprint) + fingerprint + @message: (data, version) -> new App.ControllerModal( head: 'Browser too old!' diff --git a/app/assets/javascripts/app/lib/app_post/websocket.js.coffee b/app/assets/javascripts/app/lib/app_post/websocket.js.coffee index 907907f73..7f40dafdb 100644 --- a/app/assets/javascripts/app/lib/app_post/websocket.js.coffee +++ b/app/assets/javascripts/app/lib/app_post/websocket.js.coffee @@ -115,8 +115,8 @@ class _webSocketSingleton extends App.Controller # logon websocket data = action: 'login' - session: - id: App.Session.get('id') + session_id: App.Config.get('session_id') + fingerprint: App.Browser.fingerprint() @send(data) spool: => diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7785c8063..3a6b5e58a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -140,25 +140,6 @@ class ApplicationController < ActionController::Base error_message = 'authentication failed' - # check logon session - if params['logon_session'] - logon_session = ActiveRecord::SessionStore::Session.where( session_id: params['logon_session'] ).first - - # set logon session user to current user - if logon_session - userdata = User.find( logon_session.data[:user_id] ) - current_user_set(userdata) - - session[:persistent] = true - - return { - auth: true - } - end - - error_message = 'no valid session, user_id' - end - # check sso sso_userdata = User.sso(params) if sso_userdata @@ -296,10 +277,14 @@ class ApplicationController < ActionController::Base config['timezones'][ t.name ] = diff } + # remember if we can to swich back to user if session[:switched_from_user_id] config['switch_back_to_possible'] = true end + # remember session_id for websocket logon + config['session_id'] = session.id + config end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 079ad10d8..4c4d49e2e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -42,18 +42,6 @@ class SessionsController < ApplicationController # get models models = SessionHelper.models(user) - # check logon session - logon_session_key = nil - if params['logon_session'] - logon_session_key = Digest::MD5.hexdigest( rand(999_999).to_s + Time.zone.now.to_s ) - # session = ActiveRecord::SessionStore::Session.create( - # :session_id => logon_session_key, - # :data => { - # :user_id => user['id'] - # } - # ) - end - # sessions created via this # controller are persistent session[:persistent] = true @@ -62,10 +50,10 @@ class SessionsController < ApplicationController render status: :created, json: { session: user, + config: config_frontend, models: models, collections: collections, assets: assets, - logon_session: logon_session_key, } end @@ -78,14 +66,6 @@ class SessionsController < ApplicationController user_id = session[:user_id] end - # check logon session - if params['logon_session'] - session = SessionHelper.get( params['logon_session'] ) - if session - user_id = session.data[:user_id] - end - end - if !user_id # get models models = SessionHelper.models() @@ -96,7 +76,7 @@ class SessionsController < ApplicationController models: models, collections: { Locale.to_app_model => Locale.where( active: true ) - } + }, } return end @@ -117,10 +97,10 @@ class SessionsController < ApplicationController # return current session render json: { session: user, + config: config_frontend, models: models, collections: collections, assets: assets, - config: config_frontend, } end diff --git a/lib/session_helper.rb b/lib/session_helper.rb index 1f1207a39..93de1d58a 100644 --- a/lib/session_helper.rb +++ b/lib/session_helper.rb @@ -37,7 +37,7 @@ module SessionHelper end def self.get(id) - ActiveRecord::SessionStore::Session.where( id: id ).first + ActiveRecord::SessionStore::Session.find_by( id: id ) end def self.list(limit = 10_000) @@ -45,7 +45,7 @@ module SessionHelper end def self.destroy(id) - session = ActiveRecord::SessionStore::Session.where( id: id ).first + session = ActiveRecord::SessionStore::Session.find_by( id: id ) return if !session session.destroy end diff --git a/script/websocket-server.rb b/script/websocket-server.rb index 21e75b78a..eea1877a4 100755 --- a/script/websocket-server.rb +++ b/script/websocket-server.rb @@ -12,6 +12,12 @@ require 'sessions' require 'optparse' require 'daemons' +# load rails env +dir = File.expand_path(File.join(File.dirname(__FILE__), '..')) +Dir.chdir dir +RAILS_ENV = ENV['RAILS_ENV'] || 'development' +require File.join(dir, 'config', 'environment') + # Look for -o with argument, and -I and -D boolean arguments @options = { p: 6042, @@ -176,10 +182,23 @@ EventMachine.run { # get session if data['action'] == 'login' - @clients[client_id][:session] = data['session'] - Sessions.create( client_id, data['session'], { type: 'websocket' } ) - # remember ping, send pong back + # get user_id + if data['session_id'] + session = ActiveRecord::SessionStore::Session.find_by( session_id: data['session_id'] ) + end + + if session && session.data && session.data['user_id'] + new_session_data = { 'id' => session.data['user_id'] } + else + new_session_data = {} + end + + @clients[client_id][:session] = new_session_data + + Sessions.create( client_id, new_session_data, { type: 'websocket' } ) + + # remember ping, send pong back elsif data['action'] == 'ping' Sessions.touch(client_id) @clients[client_id][:last_ping] = Time.now.utc.to_i @@ -188,7 +207,7 @@ EventMachine.run { } websocket_send(client_id, message) - # broadcast + # broadcast elsif data['action'] == 'broadcast' # list all current clients From 72bb0eb0672663177e45dde38cf32f172b053168 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 00:36:58 +0200 Subject: [PATCH 28/35] Improved device logging. --- .../app/lib/app_post/auth.js.coffee | 6 +- app/controllers/application_controller.rb | 59 +++++++++---- app/controllers/sessions_controller.rb | 6 ++ app/controllers/user_devices_controller.rb | 13 +-- app/models/user_device.rb | 86 ++++++++++++++++--- config/routes/auth.rb | 2 +- .../20150818000001_update_user_devices2.rb | 11 +++ 7 files changed, 145 insertions(+), 38 deletions(-) create mode 100644 db/migrate/20150818000001_update_user_devices2.rb diff --git a/app/assets/javascripts/app/lib/app_post/auth.js.coffee b/app/assets/javascripts/app/lib/app_post/auth.js.coffee index 6d4225960..751288a58 100644 --- a/app/assets/javascripts/app/lib/app_post/auth.js.coffee +++ b/app/assets/javascripts/app/lib/app_post/auth.js.coffee @@ -2,6 +2,7 @@ class App.Auth @login: (params) -> App.Log.notice 'Auth', 'login', params + params.data['fingerprint'] = App.Browser.fingerprint() App.Ajax.request( id: 'login' type: 'POST' @@ -21,12 +22,15 @@ class App.Auth ) @loginCheck: -> + params = + fingerprint: App.Browser.fingerprint() App.Log.debug 'Auth', 'loginCheck' App.Ajax.request( id: 'login_check' async: false - type: 'GET' + type: 'POST' url: App.Config.get('api_path') + '/signshow' + data: JSON.stringify(params) success: (data, status, xhr) => # set login (config, session, ...) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3a6b5e58a..68c72fb88 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,10 +14,10 @@ class ApplicationController < ActionController::Base :model_index_render skip_before_action :verify_authenticity_token - before_action :set_user, :session_update, :check_user_device + before_action :set_user, :session_update before_action :cors_preflight_check - after_action :set_access_control_headers + after_action :user_device_update, :set_access_control_headers after_action :trigger_events # For all responses in this controller, return the CORS access control headers. @@ -95,8 +95,8 @@ class ApplicationController < ActionController::Base session[:user_agent] = request.env['HTTP_USER_AGENT'] end - # check user device - def check_user_device + # user device recent action update + def user_device_update # return if we are in switch to user mode return if session[:switched_from_user_id] @@ -104,21 +104,46 @@ class ApplicationController < ActionController::Base # only if user_id exists return if !session[:user_id] - # only if write action + # only with user device + if !session[:user_device_id] + if params[:fingerprint] + return false if !user_device_log(current_user, 'session') + end + return + end + + # check if entry exists / only if write action return if request.method == 'GET' || request.method == 'OPTIONS' # only update if needed - return if session[:check_user_device_at] && session[:check_user_device_at] > Time.zone.now - 5.minutes - session[:check_user_device_at] = Time.zone.now + return if session[:user_device_update_at] && session[:user_device_update_at] > Time.zone.now - 5.minutes + session[:user_device_update_at] = Time.zone.now - user_device = UserDevice.add( + UserDevice.action( + session[:user_device_id], session[:user_agent], session[:remote_id], session[:user_id], ) - if user_device.id != session[:check_user_device_id] - session[:check_user_device_id] = user_device.id + end + + def user_device_log(user, type) + + # for sessions we need the fingperprint + if !params[:fingerprint] && type == 'session' + render json: { error: 'Need fingerprint param!' }, status: :unprocessable_entity + return false end + + # add defice if needed + user_device = UserDevice.add( + request.env['HTTP_USER_AGENT'], + request.remote_ip, + user.id, + params[:fingerprint], + type, + ) + session[:user_device_id] = user_device.id end def authentication_check_only(auth_param) @@ -130,7 +155,8 @@ class ApplicationController < ActionController::Base # already logged in, early exit if session.id && session[:user_id] - userdata = User.find( session[:user_id] ) + + userdata = User.find(session[:user_id]) current_user_set(userdata) return { @@ -143,11 +169,10 @@ class ApplicationController < ActionController::Base # check sso sso_userdata = User.sso(params) if sso_userdata + session[:persistent] = true current_user_set(sso_userdata) - session[:persistent] = true - return { auth: true } @@ -161,8 +186,9 @@ class ApplicationController < ActionController::Base next if !userdata - # set basic auth user to current user current_user_set(userdata) + user_device_log(userdata, 'basic_auth') + return { auth: true } @@ -180,8 +206,8 @@ class ApplicationController < ActionController::Base next if !userdata - # set token user to current user current_user_set(userdata) + user_device_log(userdata, 'token_auth') return { auth: true @@ -216,9 +242,6 @@ class ApplicationController < ActionController::Base return false end - # store current user id into the session - session[:user_id] = current_user.id - # return auth ok true end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4c4d49e2e..f84a57c91 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -30,6 +30,9 @@ class SessionsController < ApplicationController # set session user current_user_set(user) + # log device + return if !user_device_log(user, 'session') + # log new session user.activity_stream_log( 'session started', user.id, true ) @@ -85,6 +88,9 @@ class SessionsController < ApplicationController # subsequent requests user = User.find( user_id ) + # log device + return if !user_device_log(user, 'session') + # auto population of default collections collections, assets = SessionHelper.default_collections(user) diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb index e80db5115..c5b0a90bc 100644 --- a/app/controllers/user_devices_controller.rb +++ b/app/controllers/user_devices_controller.rb @@ -4,18 +4,18 @@ class UserDevicesController < ApplicationController before_action :authentication_check def index - devices = UserDevice.where(user_id: current_user.id).order('updated_at DESC') + devices = UserDevice.where(user_id: current_user.id).order('created_at DESC') devices_full = [] devices.each {|device| attributes = device.attributes - if device.location_details['city'] - attributes['location'] += ", #{device.location_details['city']}" + if device.location_details['city_name'] + attributes['location'] += ", #{device.location_details['city_name']}" end attributes.delete('created_at') attributes.delete('device_details') attributes.delete('location_details') - if session[:check_user_device_id] == device.id + if session[:user_device_id] == device.id attributes['current'] = true end devices_full.push attributes @@ -24,6 +24,7 @@ class UserDevicesController < ApplicationController end def destroy + # find device user_device = UserDevice.find_by(user_id: current_user.id, id: params[:id]) @@ -31,8 +32,8 @@ class UserDevicesController < ApplicationController if user_device SessionHelper.list.each {|session| next if !session.data['user_id'] - next if !session.data['check_user_device_id'] - next if session.data['check_user_device_id'] != user_device.id + next if !session.data['user_device_id'] + next if session.data['user_device_id'] != user_device.id SessionHelper.destroy( session.id ) } user_device.destroy diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 1e9b5649a..a247a84d7 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -13,11 +13,37 @@ store device for user 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', '172.0.0.1', user.id, + 'fingerprintABC123', + 'session', # session|basic_auth|token_auth|sso ) =end - def self.add(user_agent, ip, user_id) + def self.add(user_agent, ip, user_id, fingerprint, type) + + # get location info + location_details = Service::GeoIp.location(ip) + location = location_details['country_name'] + + # find device by fingerprint + if fingerprint + user_device = UserDevice.find_by( + user_id: user_id, + fingerprint: fingerprint, + location: location, + ) + return action(user_device.id, user_agent, ip, user_id) if user_device + end + + # for basic_auth|token_auth search for user agent + if type == 'basic_auth' || type == 'token_auth' + user_device = UserDevice.find_by( + user_id: user_id, + user_agent: user_agent, + location: location, + ) + return action(user_device.id, user_agent, ip, user_id) if user_device + end # get browser details browser = Browser.new(:ua => user_agent, :accept_language => 'en-us') @@ -37,21 +63,22 @@ store device for user name += browser[:name] end - # get location info - location = Service::GeoIp.location(ip) - country = location['country_name'] + # if not identified, use user agent + if name == 'Other, Other' + name = user_agent + browser[:name] = user_agent + end # check if exists - exists = self.find_by( - :user_id => user_id, + user_device = self.find_by( + user_id: user_id, os: browser[:plattform], browser: browser[:name], - location: country, + location: location, ) - if exists - exists.touch - return exists + if user_device + return action(user_device.id, user_agent, ip, user_id) if user_device end # create new device @@ -60,10 +87,45 @@ store device for user name: name, os: browser[:plattform], browser: browser[:name], - location: country, + location: location, device_details: browser, - location_details: location, + location_details: location_details, + user_agent: user_agent, + ip: ip, + fingerprint: fingerprint, ) + + end + +=begin + +log user device action + + UserDevice.action( + user_device_id, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '172.0.0.1', + user.id, + ) + +=end + + def self.action(user_device_id, user_agent, ip, user_id) + user_device = UserDevice.find(user_device_id) + + # update location if needed + if user_device.ip != ip + user_device.ip = ip + location_details = Service::GeoIp.location(ip) + user_device.location_details = location_details + + location = location_details['country_name'] + user_device.location = location + end + + # update attributes + user_device.save + user_device end end diff --git a/config/routes/auth.rb b/config/routes/auth.rb index 96e284d87..6807b69b3 100644 --- a/config/routes/auth.rb +++ b/config/routes/auth.rb @@ -9,7 +9,7 @@ Zammad::Application.routes.draw do # sessions match api_path + '/signin', to: 'sessions#create', via: :post - match api_path + '/signshow', to: 'sessions#show', via: :get + match api_path + '/signshow', to: 'sessions#show', via: [:get, :post] match api_path + '/signout', to: 'sessions#destroy', via: [:get, :delete] match api_path + '/sessions/switch/:id', to: 'sessions#switch_to_user', via: :get diff --git a/db/migrate/20150818000001_update_user_devices2.rb b/db/migrate/20150818000001_update_user_devices2.rb new file mode 100644 index 000000000..f8a016424 --- /dev/null +++ b/db/migrate/20150818000001_update_user_devices2.rb @@ -0,0 +1,11 @@ +class UpdateUserDevices2 < ActiveRecord::Migration + def up + add_column :user_devices, :user_agent, :string, limit: 250, null: true + add_column :user_devices, :ip, :string, limit: 160, null: true + add_column :user_devices, :fingerprint, :string, limit: 160, null: true + add_index :user_devices, [:fingerprint] + add_index :user_devices, [:created_at] + UserDevice.reset_column_information + end + +end From e2b510313a4249880f3470db3280bb776ffcb185 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 01:00:07 +0200 Subject: [PATCH 29/35] Added sending user device notification. --- app/models/user_device.rb | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/app/models/user_device.rb b/app/models/user_device.rb index a247a84d7..0fcbbd5e9 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -82,7 +82,7 @@ store device for user end # create new device - self.create( + user_device = self.create( user_id: user_id, name: name, os: browser[:plattform], @@ -95,6 +95,13 @@ store device for user fingerprint: fingerprint, ) + # send notification if needed + user_devices = UserDevice.where(user_id: user_id).count + if user_devices >= 2 + user_device.send_notification + end + + user_device end =begin @@ -128,4 +135,54 @@ log user device action user_device end +=begin + +send new user device info + + user_device = UserDevice.find(id) + + user_device.send_notification + +=end + + def send_notification + user = User.find(user_id) + + # send mail + data = {} + data[:subject] = '#{config.product_name} signin detected from a new device' + data[:body] = 'Hi #{user.firstname}, + +it looks like you signed into your #{config.product_name} account using a new device on "#{user_device.created_at}": + +Your Location: #{user_device.location} +Your IP: #{user_device.ip} + +Your device has been added to your list of known devices, which you can view here: + +#{config.http_type}://#{config.fqdn}/#profile/devices + +If this wasn\'t you, remove the device, changing your account password, and contacting your administrator. Somebody might have gained unauthorized access to your account. + +Your #{config.product_name} Team' + + # prepare subject & body + [:subject, :body].each { |key| + data[key.to_sym] = NotificationFactory.build( + locale: user.preferences[:locale], + string: data[key.to_sym], + objects: { + user_device: self, + user: user, + } + ) + } + + # send notification + NotificationFactory.send( + recipient: user, + subject: data[:subject], + body: data[:body] + ) + end end From ee5e084b6344abedb5226e4f7e38837095ca7fac Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 01:02:41 +0200 Subject: [PATCH 30/35] Improved error handling. --- script/websocket-server.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/websocket-server.rb b/script/websocket-server.rb index eea1877a4..c2b3184b8 100755 --- a/script/websocket-server.rb +++ b/script/websocket-server.rb @@ -184,7 +184,7 @@ EventMachine.run { if data['action'] == 'login' # get user_id - if data['session_id'] + if data && data['session_id'] session = ActiveRecord::SessionStore::Session.find_by( session_id: data['session_id'] ) end From c3eea0b8af59c02af66ecb7ba2b4244d68f36f86 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 01:03:28 +0200 Subject: [PATCH 31/35] Merged migrations. --- db/migrate/20150817000001_create_user_devices.rb | 9 +++++++-- db/migrate/20150817000002_update_user_devices.rb | 9 --------- db/migrate/20150818000001_update_user_devices2.rb | 11 ----------- 3 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 db/migrate/20150817000002_update_user_devices.rb delete mode 100644 db/migrate/20150818000001_update_user_devices2.rb diff --git a/db/migrate/20150817000001_create_user_devices.rb b/db/migrate/20150817000001_create_user_devices.rb index 013fb1d08..534a7c2c3 100644 --- a/db/migrate/20150817000001_create_user_devices.rb +++ b/db/migrate/20150817000001_create_user_devices.rb @@ -6,14 +6,19 @@ class CreateUserDevices < ActiveRecord::Migration t.string :name, limit: 250, null: false t.string :os, limit: 150, null: true t.string :browser, limit: 250, null: true - t.string :country, limit: 150, null: true + t.string :location, limit: 150, null: true t.string :device_details, limit: 2500, null: true t.string :location_details, limit: 2500, null: true + t.string :fingerprint, limit: 160, null: true + t.string :user_agent, limit: 250, null: true + t.string :ip, limit: 160, null: true t.timestamps end add_index :user_devices, [:user_id] - add_index :user_devices, [:os, :browser, :country] + add_index :user_devices, [:os, :browser, :location] + add_index :user_devices, [:fingerprint] add_index :user_devices, [:updated_at] + add_index :user_devices, [:created_at] end def down diff --git a/db/migrate/20150817000002_update_user_devices.rb b/db/migrate/20150817000002_update_user_devices.rb deleted file mode 100644 index 05eda7d22..000000000 --- a/db/migrate/20150817000002_update_user_devices.rb +++ /dev/null @@ -1,9 +0,0 @@ -class UpdateUserDevices < ActiveRecord::Migration - def up - add_column :user_devices, :location, :string, limit: 150, null: true - remove_column :user_devices, :country - add_index :user_devices, [:os, :browser, :location] - UserDevice.reset_column_information - end - -end diff --git a/db/migrate/20150818000001_update_user_devices2.rb b/db/migrate/20150818000001_update_user_devices2.rb deleted file mode 100644 index f8a016424..000000000 --- a/db/migrate/20150818000001_update_user_devices2.rb +++ /dev/null @@ -1,11 +0,0 @@ -class UpdateUserDevices2 < ActiveRecord::Migration - def up - add_column :user_devices, :user_agent, :string, limit: 250, null: true - add_column :user_devices, :ip, :string, limit: 160, null: true - add_column :user_devices, :fingerprint, :string, limit: 160, null: true - add_index :user_devices, [:fingerprint] - add_index :user_devices, [:created_at] - UserDevice.reset_column_information - end - -end From 9985390c93052291e5b69408064bb4ff9893fa24 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 01:05:59 +0200 Subject: [PATCH 32/35] Small improvement for displaying city. --- app/controllers/user_devices_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/user_devices_controller.rb b/app/controllers/user_devices_controller.rb index c5b0a90bc..ddebe95fc 100644 --- a/app/controllers/user_devices_controller.rb +++ b/app/controllers/user_devices_controller.rb @@ -8,7 +8,7 @@ class UserDevicesController < ApplicationController devices_full = [] devices.each {|device| attributes = device.attributes - if device.location_details['city_name'] + if device.location_details['city_name'] && !device.location_details['city_name'].empty? attributes['location'] += ", #{device.location_details['city_name']}" end attributes.delete('created_at') From f416e58ac0224b66e6cea8ee06c848e7fa288711 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 02:13:38 +0200 Subject: [PATCH 33/35] Do not device logging in user switch mode. --- app/controllers/application_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 68c72fb88..a15c01162 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -129,6 +129,9 @@ class ApplicationController < ActionController::Base def user_device_log(user, type) + # return if we are in switch to user mode + return if session[:switched_from_user_id] + # for sessions we need the fingperprint if !params[:fingerprint] && type == 'session' render json: { error: 'Need fingerprint param!' }, status: :unprocessable_entity From 1112af9e5227dc4c13425e5349f24468e99fea09 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 02:16:57 +0200 Subject: [PATCH 34/35] Do not stop rendering page. --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a15c01162..d898d77cd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -130,7 +130,7 @@ class ApplicationController < ActionController::Base def user_device_log(user, type) # return if we are in switch to user mode - return if session[:switched_from_user_id] + return true if session[:switched_from_user_id] # for sessions we need the fingperprint if !params[:fingerprint] && type == 'session' From c3be77edd4b66f9d3ec77eb84d1c5511faf8b0d8 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Wed, 19 Aug 2015 02:46:14 +0200 Subject: [PATCH 35/35] Improved device detection. --- app/controllers/application_controller.rb | 2 - app/models/user_device.rb | 11 +- test/unit/user_device_test.rb | 190 ++++++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 test/unit/user_device_test.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d898d77cd..925264107 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -174,8 +174,6 @@ class ApplicationController < ActionController::Base if sso_userdata session[:persistent] = true - current_user_set(sso_userdata) - return { auth: true } diff --git a/app/models/user_device.rb b/app/models/user_device.rb index 0fcbbd5e9..858d7813e 100644 --- a/app/models/user_device.rb +++ b/app/models/user_device.rb @@ -55,16 +55,19 @@ store device for user } # generate device name - name = browser[:plattform] || '' - if browser[:name] - if name + name = '' + if browser[:plattform] && browser[:plattform] != 'Other' + name = browser[:plattform] + end + if browser[:name] && browser[:name] != 'Other' + if name && !name.empty? name += ', ' end name += browser[:name] end # if not identified, use user agent - if name == 'Other, Other' + if !name || name == '' || name == 'Other, Other' || name == 'Other' name = user_agent browser[:name] = user_agent end diff --git a/test/unit/user_device_test.rb b/test/unit/user_device_test.rb new file mode 100644 index 000000000..8d61e9888 --- /dev/null +++ b/test/unit/user_device_test.rb @@ -0,0 +1,190 @@ +require 'test_helper' + +class UserDeviceTest < ActiveSupport::TestCase + setup do + + # create agent + groups = Group.all + roles = Role.where( name: 'Agent' ) + + UserInfo.current_user_id = 1 + + @agent = User.create_or_update( + login: 'user-device-agent@example.com', + firstname: 'UserDevice', + lastname: 'Agent', + email: 'user-device-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + end + + + test 'session test' do + + # signin with fingerprint A from country A via session -> new device #1 + user_device1 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '91.115.248.231', + @agent.id, + 'fingerprint1234', + 'session', + ) + + # signin with fingerprint A from country B via session -> new device #2 + user_device2 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '176.198.137.254', + @agent.id, + 'fingerprint1234', + 'session', + ) + assert_not_equal(user_device1.id, user_device2.id) + + # signin with fingerprint B from country A via session -> new device #3 + user_device3 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '91.115.248.231', + @agent.id, + 'fingerprintABC', + 'session', + ) + assert_not_equal(user_device2.id, user_device3.id) + + # signin with fingerprint A from country A via session -> new device #1 + user_device4 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '91.115.248.231', + @agent.id, + 'fingerprint1234', + 'session', + ) + assert_equal(user_device1.id, user_device4.id) + + # signin with fingerprint A from country B via session -> new device #2 + user_device5 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '176.198.137.254', + @agent.id, + 'fingerprint1234', + 'session', + ) + assert_equal(user_device2.id, user_device5.id) + + # signin with fingerprint B from country A via session -> new device #3 + user_device6 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '91.115.248.231', + @agent.id, + 'fingerprintABC', + 'session', + ) + assert_equal(user_device3.id, user_device6.id) + + end + + test 'session test - user agent (unknown)' do + + # known user agent + user_device1 = UserDevice.add( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', + '91.115.248.231', + @agent.id, + nil, + 'session', + ) + assert_equal('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.107 Safari/537.36', user_device1.user_agent) + assert_equal('Mac, Chrome', user_device1.name) + + # unknown user agent + user_device2 = UserDevice.add( + 'ABC 123', + '91.115.248.231', + @agent.id, + nil, + 'session', + ) + assert_equal('ABC 123', user_device2.user_agent) + assert_equal('ABC 123', user_device2.browser) + assert_equal('ABC 123', user_device2.name) + + # partently known + user_device3 = UserDevice.add( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_4 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H143 Safari/600.1.4', + '91.115.248.231', + @agent.id, + nil, + 'session', + ) + assert_equal('Mozilla/5.0 (iPhone; CPU iPhone OS 8_4 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H143 Safari/600.1.4', user_device3.user_agent) + assert_equal('iPhone', user_device3.browser) + assert_equal('iPhone', user_device3.name) + end + + test 'api test' do + + # signin with ua from country A via basic auth -> new device #1 + user_device1 = UserDevice.add( + 'curl/7.43.0', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + + # signin with ua from country B via basic auth -> new device #2 + user_device2 = UserDevice.add( + 'curl/7.43.0', + '176.198.137.254', + @agent.id, + nil, + 'basic_auth', + ) + assert_not_equal(user_device1.id, user_device2.id) + + # signin with ua from country A via basic auth -> new device #1 + user_device3 = UserDevice.add( + 'curl/7.43.0', + '91.115.248.231', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device1.id, user_device3.id) + + # signin with ua from country B via basic auth -> new device #2 + user_device4 = UserDevice.add( + 'curl/7.43.0', + '176.198.137.254', + @agent.id, + nil, + 'basic_auth', + ) + assert_equal(user_device2.id, user_device4.id) + + # signin with ua from country A via token auth -> new device #1 + user_device5 = UserDevice.add( + 'curl/7.43.0', + '91.115.248.231', + @agent.id, + nil, + 'token_auth', + ) + assert_equal(user_device1.id, user_device5.id) + + # signin with ua from country B via token auth -> new device #2 + user_device6 = UserDevice.add( + 'curl/7.43.0', + '176.198.137.254', + @agent.id, + nil, + 'token_auth', + ) + assert_equal(user_device2.id, user_device6.id) + + end + + +end