diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 854c3cad1..9f9a8c941 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,7 +1,8 @@ import logging import json +import datetime -from flask import Blueprint, request, make_response, jsonify +from flask import Blueprint, request, make_response, jsonify, session from flask.ext.restful import Resource, abort, Api, reqparse from flask.ext.restful.utils.cors import crossdomain from werkzeug.exceptions import HTTPException @@ -66,6 +67,11 @@ class Unauthorized(ApiException): ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload) +class FreshLoginRequired(ApiException): + def __init__(self, payload=None): + ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload) + + class ExceedsLicenseException(ApiException): def __init__(self, payload=None): ApiException.__init__(self, None, 402, 'Payment Required', payload) @@ -264,6 +270,26 @@ def require_user_permission(permission_class, scope=None): require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER) require_user_admin = require_user_permission(UserAdminPermission, None) +require_fresh_user_admin = require_user_permission(UserAdminPermission, None) + +def require_fresh_login(func): + @add_method_metadata('requires_fresh_login', True) + @wraps(func) + def wrapped(*args, **kwargs): + user = get_authenticated_user() + if not user: + raise Unauthorized() + + logger.debug('Checking fresh login for user %s', user.username) + + last_login = session.get('login_time', datetime.datetime.now() - datetime.timedelta(minutes=60)) + valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10) + + if last_login >= valid_span: + return func(*args, **kwargs) + + raise FreshLoginRequired() + return wrapped def require_scope(scope_object): diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py index ee8702636..1995c6b42 100644 --- a/endpoints/api/discovery.py +++ b/endpoints/api/discovery.py @@ -119,6 +119,11 @@ def swagger_route_data(include_internal=False, compact=False): if internal is not None: new_operation['internal'] = True + if include_internal: + requires_fresh_login = method_metadata(method, 'requires_fresh_login') + if requires_fresh_login is not None: + new_operation['requires_fresh_login'] = True + if not internal or (internal and include_internal): operations.append(new_operation) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 054db7041..eb99ba0fa 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -9,7 +9,7 @@ from app import app, billing as stripe, authentication from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, log_action, internal_only, NotFound, require_user_admin, parse_args, query_param, InvalidToken, require_scope, format_date, hide_if, show_if, - license_error) + license_error, require_fresh_login) from endpoints.api.subscribe import subscribe from endpoints.common import common_login from data import model @@ -117,10 +117,6 @@ class User(ApiResource): 'type': 'object', 'description': 'Fields which can be updated in a user.', 'properties': { - 'current_password': { - 'type': 'string', - 'description': 'The user\'s current password', - }, 'password': { 'type': 'string', 'description': 'The user\'s password', @@ -148,6 +144,7 @@ class User(ApiResource): return user_view(user) @require_user_admin + @require_fresh_login @nickname('changeUserDetails') @internal_only @validate_json_request('UpdateUser') @@ -156,22 +153,8 @@ class User(ApiResource): user = get_authenticated_user() user_data = request.get_json() - def verify_current_password(user, user_data): - current_password = user_data.get('current_password', '') - - verified = False - try: - verified = model.verify_user(user.username, current_password) - except: - pass - - if not verified: - raise request_error(message='Current password does not match') - try: if 'password' in user_data: - verify_current_password(user, user_data) - logger.debug('Changing password for user: %s', user.username) log_action('account_change_password', user.username) model.change_password(user, user_data['password']) @@ -181,8 +164,6 @@ class User(ApiResource): model.change_invoice_email(user, user_data['invoice_email']) if 'email' in user_data and user_data['email'] != user.email: - verify_current_password(user, user_data) - new_email = user_data['email'] if model.find_user_by_email(new_email): # Email already used. @@ -377,6 +358,37 @@ class Signin(ApiResource): return conduct_signin(username, password) +@resource('/v1/signin/verify') +@internal_only +class VerifyUser(ApiResource): + """ Operations for verifying the existing user. """ + schemas = { + 'VerifyUser': { + 'id': 'VerifyUser', + 'type': 'object', + 'description': 'Information required to verify the signed in user.', + 'required': [ + 'password', + ], + 'properties': { + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + }, + }, + } + + @require_user_admin + @nickname('verifyUser') + @validate_json_request('VerifyUser') + def post(self): + """ Verifies the signed in the user with the specified credentials. """ + signin_data = request.get_json() + password = signin_data['password'] + return conduct_signin(get_authenticated_user().username, password) + + @resource('/v1/signout') @internal_only class Signout(ApiResource): diff --git a/endpoints/common.py b/endpoints/common.py index fe09104ca..52715a1d1 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -2,8 +2,9 @@ import logging import urlparse import json import string +import datetime -from flask import make_response, render_template, request, abort +from flask import make_response, render_template, request, abort, session from flask.ext.login import login_user, UserMixin from flask.ext.principal import identity_changed from random import SystemRandom @@ -112,6 +113,7 @@ def common_login(db_user): logger.debug('Successfully signed in as: %s' % db_user.username) new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN}) identity_changed.send(app, identity=new_identity) + session['login_time'] = datetime.datetime.now() return True else: logger.debug('User could not be logged in, inactive?.') diff --git a/static/js/app.js b/static/js/app.js index 72eabc41a..d23235a66 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -713,7 +713,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return config; }]); - $provide.factory('ApiService', ['Restangular', function(Restangular) { + $provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) { var apiService = {}; var getResource = function(path, opt_background) { @@ -810,6 +810,65 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading } }; + var freshLoginFailCheck = function(opName, opArgs) { + return function(resp) { + var deferred = $q.defer(); + + // If the error is a fresh login required, show the dialog. + if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') { + bootbox.dialog({ + "message": 'It has been more than a few minutes since you last logged in, ' + + 'so please verify your password to perform this sensitive operation:' + + '
', + "title": 'Please Verify', + "buttons": { + "verify": { + "label": "Verify", + "className": "btn-success", + "callback": function() { + var info = { + 'password': $('#freshPassword').val() + }; + + $('#freshPassword').val(''); + + // Conduct the sign in of the user. + apiService.verifyUser(info).then(function() { + // On success, retry the operation. if it succeeds, then resolve the + // deferred promise with the result. Otherwise, reject the same. + apiService[opName].apply(apiService, opArgs).then(function(resp) { + deferred.resolve(resp); + }, function(resp) { + deferred.reject(resp); + }); + }, function(resp) { + // Reject with the sign in error. + deferred.reject({'data': {'message': 'Invalid verification credentials'}}); + }); + } + }, + "close": { + "label": "Cancel", + "className": "btn-default", + "callback": function() { + deferred.reject(resp); + } + } + } + }); + + // Return a new promise. We'll accept or reject it based on the result + // of the login. + return deferred.promise; + } + + // Otherwise, we just 'raise' the error via the reject method on the promise. + return $q.reject(resp); + }; + }; + var buildMethodsForOperation = function(operation, resource, resourceMap) { var method = operation['method'].toLowerCase(); var operationName = operation['nickname']; @@ -823,7 +882,15 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading 'ignoreLoadingBar': true }); } - return one['custom' + method.toUpperCase()](opt_options); + + var opObj = one['custom' + method.toUpperCase()](opt_options); + + // If the operation requires_fresh_login, then add a specialized error handler that + // will defer the operation's result if sudo is requested. + if (operation['requires_fresh_login']) { + opObj = opObj.catch(freshLoginFailCheck(operationName, arguments)); + } + return opObj; }; // If the method for the operation is a GET, add an operationAsResource method. @@ -3923,9 +3990,11 @@ quayApp.directive('billingOptions', function () { var save = function() { $scope.working = true; + + var errorHandler = ApiService.errorDisplay('Could not change user details'); ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { $scope.working = false; - }); + }, errorHandler); }; var checkSave = function() { @@ -5699,11 +5768,10 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi // Handle session expiration. Restangular.setErrorInterceptor(function(response) { - if (response.status == 401) { - if (response.data['session_required'] == null || response.data['session_required'] === true) { - $('#sessionexpiredModal').modal({}); - return false; - } + if (response.status == 401 && response.data['error_type'] == 'invalid_token' && + response.data['session_required'] !== false) { + $('#sessionexpiredModal').modal({}); + return false; } if (response.status == 503) { diff --git a/static/js/controllers.js b/static/js/controllers.js index 485b7f529..d95a760a9 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -1763,12 +1763,11 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use // Reset the form. delete $scope.cuser['repeatEmail']; - delete $scope.cuser['current_password']; $scope.changeEmailForm.$setPristine(); }, function(result) { $scope.updatingUser = false; - UIService.showFormError('#changeEmailForm', result); + UIService.showFormError('#changeEmailForm', result); }); }; @@ -1778,14 +1777,14 @@ function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, Use $scope.updatingUser = true; $scope.changePasswordSuccess = false; - ApiService.changeUserDetails($scope.cuser).then(function() { + ApiService.changeUserDetails($scope.cuser).then(function(resp) { + $scope.updatingUser = false; $scope.changePasswordSuccess = true; // Reset the form delete $scope.cuser['password'] delete $scope.cuser['repeatPassword'] - delete $scope.cuser['current_password']; $scope.changePasswordForm.$setPristine(); diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 260aa47e2..4349d9df9 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -128,8 +128,6 @@