From 51aae28a1ef0bd0e2431e5a34290f0a75ed4c763 Mon Sep 17 00:00:00 2001 From: Chris Houseknecht Date: Tue, 5 Nov 2013 17:36:19 +0000 Subject: [PATCH] AC-471 Added back client session timeout. Fixed Rest service library to bubble up expired session and invalid token errors via promise object, enabling correct error handling. Now tracking last URL in session cookie and returning user to last URL after successful login. --- awx/ui/static/js/app.js | 30 +++-- awx/ui/static/js/config.js | 8 +- .../static/js/controllers/Authentication.js | 35 +++++- awx/ui/static/js/forms/JobTemplates.js | 4 +- awx/ui/static/js/helpers/search.js | 2 +- awx/ui/static/lib/ansible/AuthService.js | 4 +- awx/ui/static/lib/ansible/RestServices.js | 109 ++++++++++++------ awx/ui/static/lib/ansible/Timer.js | 55 +++++++++ awx/ui/static/lib/ansible/Utilities.js | 14 +-- awx/ui/static/lib/ansible/form-generator.js | 69 ++++++----- awx/ui/templates/ui/index.html | 1 + 11 files changed, 238 insertions(+), 93 deletions(-) create mode 100644 awx/ui/static/lib/ansible/Timer.js diff --git a/awx/ui/static/js/app.js b/awx/ui/static/js/app.js index cc80427b2b..75dabd23f7 100644 --- a/awx/ui/static/js/app.js +++ b/awx/ui/static/js/app.js @@ -79,7 +79,8 @@ angular.module('ansible', [ 'InventorySummaryHelpDefinition', 'InventoryHostsHelpDefinition', 'TreeSelector', - 'CredentialsHelper' + 'CredentialsHelper', + 'TimerService' ]) .config(['$routeProvider', function($routeProvider) { $routeProvider. @@ -248,25 +249,38 @@ angular.module('ansible', [ otherwise({redirectTo: '/home'}); }]) .run(['$cookieStore', '$rootScope', 'CheckLicense', '$location', 'Authorization','LoadBasePaths', 'ViewLicense', - function($cookieStore, $rootScope, CheckLicense, $location, Authorization, LoadBasePaths, ViewLicense) { + 'Timer', + function($cookieStore, $rootScope, CheckLicense, $location, Authorization, LoadBasePaths, ViewLicense, + Timer) { LoadBasePaths(); - if ( !(typeof $AnsibleConfig.refresh_rate == 'number' && $AnsibleConfig.refresh_rate >= 3 - && $AnsibleConfig.refresh_rate <= 99) ) { - $AnsibleConfig.refresh_rate = 10; - } - $rootScope.breadcrumbs = new Array(); $rootScope.crumbCache = new Array(); - + $rootScope.sessionTimer = Timer.init(); + $rootScope.$on("$routeChangeStart", function(event, next, current) { // On each navigation request, check that the user is logged in + + var tst = /login/; + var path = $location.path(); + if ( !tst.test($location.path()) ) { + // capture most recent URL, excluding login + $rootScope.lastPath = path; + $cookieStore.put('lastPath', path); + } + if (Authorization.isUserLoggedIn() == false) { if ( next.templateUrl != (urlPrefix + 'partials/login.html') ) { $location.path('/login'); } } + else if ($rootScope.sessionTimer.isExpired()) { + if ( next.templateUrl != (urlPrefix + 'partials/login.html') ) { + $rootScope.sessionTimer.expireSession(); + $location.path('/login'); + } + } else { if ($rootScope.current_user == undefined || $rootScope.current_user == null) { Authorization.restoreUserInfo(); //user must have hit browser refresh diff --git a/awx/ui/static/js/config.js b/awx/ui/static/js/config.js index 430042ceac..ae6fbec83d 100644 --- a/awx/ui/static/js/config.js +++ b/awx/ui/static/js/config.js @@ -14,13 +14,13 @@ var $AnsibleConfig = debug_mode: true, // Enable console logging messages - refresh_rate: 10, // Number of seconds before refreshing a page. Integer between 3 and 99, inclusive. - // Used by awRefresh directive to automatically refresh Jobs and Projects pages. - - password_strength: 45 // User password strength. Integer between 0 and 100, 100 being impossibly strong. + password_strength: 45, // User password strength. Integer between 0 and 100, 100 being impossibly strong. // This value controls progress bar colors: // 0 to password_strength - 15 = red; // password_strength - 15 to password_strength = yellow // > password_strength = green // It also controls password validation. Passwords are rejected if the score is not > password_strength. + + session_timeout: 15 // Number of seconds before an inactive session is automatically timed out and forced to log in again. + // Separate from time out value set in API. } diff --git a/awx/ui/static/js/controllers/Authentication.js b/awx/ui/static/js/controllers/Authentication.js index 43562943ea..bb1a7ae34f 100644 --- a/awx/ui/static/js/controllers/Authentication.js +++ b/awx/ui/static/js/controllers/Authentication.js @@ -10,12 +10,33 @@ 'use strict'; -function Authenticate($cookieStore, $window, $scope, $rootScope, $location, Authorization, ToggleClass, Alert, Wait) +function Authenticate($cookieStore, $window, $scope, $rootScope, $location, Authorization, ToggleClass, Alert, Wait, + Timer, Empty) { var setLoginFocus = function() { $('#login-username').focus(); }; + var sessionExpired = function() { + return (Empty($rootScope.sessionExpired)) ? $cookieStore.get('sessionExpired') : $rootScope.sessionExpired; + }(); + + var lastPath = function() { + return (Empty($rootScope.lastPath)) ? $cookieStore.get('lastPath') : $rootScope.lastPath; + }(); + + if ($AnsibleConfig.debug_mode && console) { + console.log('User session expired: ' + sessionExpired); + console.log('Last URL: ' + lastPath); + } + + // Hide any lingering modal dialogs + $('.modal[aria-hidden=false]').each( function() { + if ($(this).attr('id') !== 'login-modal') { + $(this).modal('hide'); + } + }); + // Just in case, make sure the wait widget is not active Wait('stop'); @@ -70,6 +91,7 @@ function Authenticate($cookieStore, $window, $scope, $rootScope, $location, Auth $('#login-modal').modal('hide'); token = data.token; Authorization.setToken(data.token, data.expires); + $rootScope.sessionTimer = Timer.init(); // Get all the profile/access info regarding the logged in user Authorization.getUser() .success(function(data, status, headers, config) { @@ -77,7 +99,13 @@ function Authenticate($cookieStore, $window, $scope, $rootScope, $location, Auth Authorization.getLicense() .success(function(data, status, headers, config) { Authorization.setLicense(data['license_info']); - $location.url('/home?login=true'); + if (lastPath) { + // Go back to most recent navigation path + $location.path(lastPath); + } + else { + $location.url('/home?login=true'); + } }) .error(function(data, status, headers, config) { Alert('Error', 'Failed to access user information. GET returned status: ' + status, 'alert-danger', setLoginFocus); @@ -113,5 +141,6 @@ function Authenticate($cookieStore, $window, $scope, $rootScope, $location, Auth } } -Authenticate.$inject = ['$cookieStore', '$window', '$scope', '$rootScope', '$location', 'Authorization', 'ToggleClass', 'Alert', 'Wait']; +Authenticate.$inject = ['$cookieStore', '$window', '$scope', '$rootScope', '$location', 'Authorization', 'ToggleClass', 'Alert', 'Wait', + 'Timer', 'Empty']; diff --git a/awx/ui/static/js/forms/JobTemplates.js b/awx/ui/static/js/forms/JobTemplates.js index 329ebb969e..438b7d9cee 100644 --- a/awx/ui/static/js/forms/JobTemplates.js +++ b/awx/ui/static/js/forms/JobTemplates.js @@ -78,8 +78,8 @@ angular.module('JobTemplateFormDefinition', []) sourceModel: 'credential', sourceField: 'name', ngClick: 'lookUpCredential()', - addRequired: false, - editRequired: false, + addRequired: true, + editRequired: true, column: 1 }, cloud_credential: { diff --git a/awx/ui/static/js/helpers/search.js b/awx/ui/static/js/helpers/search.js index 6e42d74ae0..40cd8d46b1 100644 --- a/awx/ui/static/js/helpers/search.js +++ b/awx/ui/static/js/helpers/search.js @@ -65,7 +65,7 @@ angular.module('SearchHelper', ['RestServices', 'Utilities', 'RefreshHelper']) var f = scope[iterator + 'SearchField'] if (list.fields[f].searchType && ( list.fields[f].searchType == 'boolean' - || list.fields[f].searchType == 'select')) { + || list.fields[f].searchType == 'select')) { scope[iterator + 'SelectShow'] = true; scope[iterator + 'SearchSelectOpts'] = list.fields[f].searchOptions; } diff --git a/awx/ui/static/lib/ansible/AuthService.js b/awx/ui/static/lib/ansible/AuthService.js index c973a3adff..f104298a64 100644 --- a/awx/ui/static/lib/ansible/AuthService.js +++ b/awx/ui/static/lib/ansible/AuthService.js @@ -51,7 +51,7 @@ angular.module('AuthService', ['ngCookies', 'Utilities']) $rootScope.$destroy(); $cookieStore.remove('accordions'); $cookieStore.remove('token'); - $cookieStore.remove('token_expire'); + $cookieStore.remove('token_expires'); $cookieStore.remove('current_user'); $cookieStore.put('userLoggedIn', false); $cookieStore.put('sessionExpired', false); @@ -60,7 +60,7 @@ angular.module('AuthService', ['ngCookies', 'Utilities']) $rootScope.userLoggedIn = false; $rootScope.sessionExpired = false; $rootScope.token = null; - $rootScope.token_expire = new Date(1970, 0, 1, 0, 0, 0, 0); + $rootScope.token_expires = null; }, getLicense: function() { diff --git a/awx/ui/static/lib/ansible/RestServices.js b/awx/ui/static/lib/ansible/RestServices.js index f2ea985d0a..2ef2ed2926 100644 --- a/awx/ui/static/lib/ansible/RestServices.js +++ b/awx/ui/static/lib/ansible/RestServices.js @@ -5,13 +5,16 @@ * */ angular.module('RestServices',['ngCookies','AuthService']) - .factory('Rest', ['$http','$rootScope','$cookieStore','Authorization', function($http, $rootScope, $cookieStore, Authorization) { +.factory('Rest', ['$http','$rootScope','$cookieStore', '$q', 'Authorization', +function($http, $rootScope, $cookieStore, $q, Authorization) { return { setUrl: function (url) { this.url = url; }, - + checkExpired: function() { + return $rootScope.sessionTimer.isExpired(); + }, pReplace: function() { //in our url, replace :xx params with a value, assuming //we can find it in user supplied params. @@ -24,69 +27,107 @@ angular.module('RestServices',['ngCookies','AuthService']) } } }, - + createResponse: function(data, status) { + // Simulate an http response when a token error occurs + // http://stackoverflow.com/questions/18243286/angularjs-promises-simulate-http-promises + + var promise = $q.reject({ data: data, status: status }); + promise.success = function(fn){ + promise.then(function(response){ fn(response.data, response.status) }, null); + return promise + }; + promise.error = function(fn){ + promise.then(null, function(response){ fn(response.data, response.status) }); + return promise; + }; + return promise; + }, get: function(args) { args = (args) ? args : {}; this.params = (args.params) ? args.params : null; this.pReplace(); + var expired = this.checkExpired(); var token = Authorization.getToken(); - if (token) { - return $http({method: 'GET', - url: this.url, - headers: { 'Authorization': 'Token ' + token }, - params: this.params - }); + if (expired) { + return this.createResponse({ detail: 'Token is expired' }, 401); + } + else if (token) { + return $http({method: 'GET', + url: this.url, + headers: { 'Authorization': 'Token ' + token }, + params: this.params + }); } else { - return false; + return this.createResponse({ detail: 'Invalid token' }, 401); } }, post: function(data) { var token = Authorization.getToken(); - if (token) { - return $http({method: 'POST', - url: this.url, - headers: { 'Authorization': 'Token ' + token }, - data: data }); + var expired = this.checkExpired(); + if (expired) { + return this.createResponse({ detail: 'Token is expired' }, 401); + } + else if (token) { + return $http({ + method: 'POST', + url: this.url, + headers: { 'Authorization': 'Token ' + token }, + data: data }); } else { - return false; + return this.createResponse({ detail: 'Invalid token' }, 401); } }, put: function(data) { var token = Authorization.getToken(); - if (token) { - return $http({method: 'PUT', - url: this.url, - headers: { 'Authorization': 'Token ' + token }, - data: data }); + var expired = this.checkExpired(); + if (expired) { + return this.createResponse({ detail: 'Token is expired' }, 401); + } + else if (token) { + return $http({ + method: 'PUT', + url: this.url, + headers: { 'Authorization': 'Token ' + token }, + data: data }); } else { - return false; + return this.createResponse({ detail: 'Invalid token' }, 401); } }, destroy: function(data) { var token = Authorization.getToken(); - if (token) { - return $http({method: 'DELETE', - url: this.url, - headers: { 'Authorization': 'Token ' + token }, - data: data}); + var expired = this.checkExpired(); + if (expired) { + return this.createResponse({ detail: 'Token is expired' }, 401); } + else if (token) { + return $http({ + method: 'DELETE', + url: this.url, + headers: { 'Authorization': 'Token ' + token }, + data: data }); + } else { - return false; + return this.createResponse({ detail: 'Invalid token' }, 401); } }, options: function() { var token = Authorization.getToken(); - if (token) { - return $http({method: 'OPTIONS', - url: this.url, - headers: { 'Authorization': 'Token ' + token } - }); + var expired = this.checkExpired(); + if (expired) { + return this.createResponse({ detail: 'Token is expired' }, 401); + } + else if (token) { + return $http({ + method: 'OPTIONS', + url: this.url, + headers: { 'Authorization': 'Token ' + token } + }); } else { - return false; + return this.createResponse({ detail: 'Invalid token' }, 401); } } } diff --git a/awx/ui/static/lib/ansible/Timer.js b/awx/ui/static/lib/ansible/Timer.js new file mode 100644 index 0000000000..6e79e7dbde --- /dev/null +++ b/awx/ui/static/lib/ansible/Timer.js @@ -0,0 +1,55 @@ +/************************************************** + * Copyright (c) 2013 AnsibleWorks, Inc. + * + * Timer.js + * + * Use to track user idle time and expire session. Timeout + * duration set in config.js + * + */ +angular.module('TimerService', ['ngCookies', 'Utilities']) + .factory('Timer', ['$rootScope', '$cookieStore', '$location', 'GetBasePath', 'Empty', + function($rootScope, $cookieStore, $location, GetBasePath, Empty) { + return { + + sessionTime: null, + timeout: null, + + getSessionTime: function() { + return (this.sessionTime) ? this.sessionTime : $cookieStore.get('sessionTime'); + }, + + isExpired: function() { + var stime = this.getSessionTime(); + var now = new Date().getTime(); + if ((stime - now) <= 0) { + //expired + return true; + } + else { + // not expired. move timer forward. + this.moveForward(); + return false; + } + }, + + expireSession: function() { + this.sessionTime = 0; + $rootScope.sessionExpired = true; + $cookieStore.put('sessionExpired', true); + }, + + moveForward: function() { + var t = new Date().getTime() + ($AnsibleConfig.session_timeout * 1000); + this.sessionTime = t; + $cookieStore.put('sessionTime', t); + $rootScope.sessionExpired = false; + $cookieStore.put('sessionExpired', false); + }, + + init: function() { + this.moveForward(); + return this; + } + } + }]); \ No newline at end of file diff --git a/awx/ui/static/lib/ansible/Utilities.js b/awx/ui/static/lib/ansible/Utilities.js index 1b5585945d..bf39e22598 100644 --- a/awx/ui/static/lib/ansible/Utilities.js +++ b/awx/ui/static/lib/ansible/Utilities.js @@ -91,8 +91,8 @@ angular.module('Utilities',['RestServices', 'Utilities']) } }]) - .factory('ProcessErrors', ['$cookieStore', '$log', '$location', '$rootScope', 'Alert', - function($cookieStore, $log, $location, $rootScope, Alert) { + .factory('ProcessErrors', ['$rootScope', '$cookieStore', '$log', '$location', 'Alert', + function($rootScope, $cookieStore, $log, $location, Alert) { return function(scope, data, status, form, defaultMsg) { if ($AnsibleConfig.debug_mode && console) { console.log('Debug status: ' + status); @@ -110,15 +110,13 @@ angular.module('Utilities',['RestServices', 'Utilities']) Alert(defaultMsg.hdr, msg); } else if (status == 401 && data.detail && data.detail == 'Token is expired') { - $rootScope.sessionExpired = true; - $cookieStore.put('sessionExpired', true); - $location.path('/login'); + $rootScope.sessionTimer.expireSession(); + window.location = '/#/login'; //resetting location so that we drop search params } else if (status == 401 && data.detail && data.detail == 'Invalid token') { // should this condition be treated as an expired session?? Yes, for now. - $rootScope.sessionExpired = true; - $cookieStore.put('sessionExpired', true); - $location.path('/login'); + $rootScope.sessionTimer.expireSession(); + window.location = '/#/login'; //resetting location so that we drop search params } else if (data.non_field_errors) { Alert('Error!', data.non_field_errors); diff --git a/awx/ui/static/lib/ansible/form-generator.js b/awx/ui/static/lib/ansible/form-generator.js index c955a061bf..404174412a 100644 --- a/awx/ui/static/lib/ansible/form-generator.js +++ b/awx/ui/static/lib/ansible/form-generator.js @@ -9,10 +9,10 @@ */ angular.module('FormGenerator', ['GeneratorHelpers', 'ngCookies', 'Utilities']) - .factory('GenerateForm', [ '$location', '$cookieStore', '$compile', 'SearchWidget', 'PaginateWidget', 'Attr', 'Icon', 'Column', - 'NavigationLink', 'HelpCollapse', 'Button', 'DropDown', 'Empty', - function($location, $cookieStore, $compile, SearchWidget, PaginateWidget, Attr, Icon, Column, NavigationLink, HelpCollapse, Button, - DropDown, Empty) { + .factory('GenerateForm', ['$rootScope', '$location', '$cookieStore', '$compile', 'SearchWidget', 'PaginateWidget', 'Attr', + 'Icon', 'Column', 'NavigationLink', 'HelpCollapse', 'Button', 'DropDown', 'Empty', + function($rootScope, $location, $cookieStore, $compile, SearchWidget, PaginateWidget, Attr, Icon, Column, NavigationLink, + HelpCollapse, Button, DropDown, Empty) { return { setForm: function(form) { @@ -26,14 +26,15 @@ angular.module('FormGenerator', ['GeneratorHelpers', 'ngCookies', 'Utilities']) accordion_count: 0, has: function(key) { - return (this.form[key] && this.form[key] != null && this.form[key] != undefined) ? true : false; - }, - + return (this.form[key] && this.form[key] != null && this.form[key] != undefined) ? true : false; + }, + inject: function(form, options) { // // Use to inject the form as html into the view. View MUST have an ng-bind for 'htmlTemplate'. // Returns scope of form. // + var element; if (options.modal) { if (options.modal_body_id) { @@ -164,7 +165,9 @@ angular.module('FormGenerator', ['GeneratorHelpers', 'ngCookies', 'Utilities']) if (!this.scope.$$phase) { this.scope.$digest(); } + return this.scope; + }, applyDefaults: function() { @@ -181,42 +184,46 @@ angular.module('FormGenerator', ['GeneratorHelpers', 'ngCookies', 'Utilities']) }, reset: function() { - // The form field values cannot be reset with jQuery. Each field is tied to a model, so to clear the field - // value, you have to clear the model. - this.scope[this.form.name + '_form'].$setPristine(); - for (var fld in this.form.fields) { - if (this.form.fields[fld].type == 'checkbox_group') { + // The form field values cannot be reset with jQuery. Each field is tied to a model, so to clear the field + // value, you have to clear the model. + + if (this.scope[this.form.name + '_form']) { + this.scope[this.form.name + '_form'].$setPristine(); + } + + for (var fld in this.form.fields) { + if (this.form.fields[fld].type == 'checkbox_group') { for (var i=0; i < this.form.fields[fld].fields.length; i++) { this.scope[this.form.fields[fld].fields[i].name] = ''; this.scope[this.form.fields[fld].fields[i].name + '_api_error'] = ''; } - } - else { + } + else { this.scope[fld] = ''; this.scope[fld + '_api_error'] = ''; - } - if (this.form.fields[fld].sourceModel) { + } + if (this.form.fields[fld].sourceModel) { this.scope[this.form.fields[fld].sourceModel + '_' + this.form.fields[fld].sourceField] = ''; this.scope[this.form.fields[fld].sourceModel + '_' + this.form.fields[fld].sourceField + '_api_error'] = ''; - } - if ( this.form.fields[fld].type == 'lookup' && + } + if ( this.form.fields[fld].type == 'lookup' && this.scope[this.form.name + '_form'][this.form.fields[fld].sourceModel + '_' + this.form.fields[fld].sourceField] ) { this.scope[this.form.name + '_form'][this.form.fields[fld].sourceModel + '_' + this.form.fields[fld].sourceField].$setPristine(); - } - if (this.scope[this.form.name + '_form'][fld]) { + } + if (this.scope[this.form.name + '_form'][fld]) { this.scope[this.form.name + '_form'][fld].$setPristine(); - } - //if (this.scope.fields[fld].awPassMatch) { - // this.scope[this.form.name + '_form'][fld].$setValidity('awpassmatch', true); - //} - if (this.form.fields[fld].ask) { + } + //if (this.scope.fields[fld].awPassMatch) { + // this.scope[this.form.name + '_form'][fld].$setValidity('awpassmatch', true); + //} + if (this.form.fields[fld].ask) { this.scope[fld + '_ask'] = false; - } - } - if (this.mode == 'add') { - this.applyDefaults(); - } - }, + } + } + if (this.mode == 'add') { + this.applyDefaults(); + } + }, addListeners: function() { diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index 607bcd6db2..5bf43b29b1 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -40,6 +40,7 @@ +