Explorar el Código

Merge branch 'sk-sudo-mode'

Shane Kilkelly hace 9 años
padre
commit
6338835baf

+ 63 - 0
services/web/app/coffee/Features/SudoMode/SudoModeController.coffee

@@ -0,0 +1,63 @@
+logger = require 'logger-sharelatex'
+SudoModeHandler = require './SudoModeHandler'
+AuthenticationController = require '../Authentication/AuthenticationController'
+AuthenticationManager = require '../Authentication/AuthenticationManager'
+ObjectId = require('../../infrastructure/Mongoose').mongo.ObjectId
+UserGetter = require '../User/UserGetter'
+
+
+module.exports = SudoModeController =
+
+	sudoModePrompt: (req, res, next) ->
+		if req.externalAuthenticationSystemUsed()
+			logger.log {userId}, "[SudoMode] using external auth, redirecting"
+			return res.redirect('/project')
+		userId = AuthenticationController.getLoggedInUserId(req)
+		logger.log {userId}, "[SudoMode] rendering sudo mode password page"
+		SudoModeHandler.isSudoModeActive userId, (err, isActive) ->
+			if err?
+				logger.err {err, userId}, "[SudoMode] error checking if sudo mode is active"
+				return next(err)
+			if isActive
+				logger.log {userId}, "[SudoMode] sudo mode already active, redirecting"
+				return res.redirect('/project')
+			res.render 'sudo_mode/sudo_mode_prompt', title: 'confirm_password_to_continue'
+
+	submitPassword: (req, res, next) ->
+		userId = AuthenticationController.getLoggedInUserId(req)
+		redir = AuthenticationController._getRedirectFromSession(req) || "/project"
+		password = req.body.password
+		if !password
+			logger.log {userId}, "[SudoMode] no password supplied, failed authentication"
+			return next(new Error('no password supplied'))
+		logger.log {userId, redir}, "[SudoMode] checking user password"
+		UserGetter.getUser ObjectId(userId), {email: 1}, (err, userRecord) ->
+			if err?
+				logger.err {err, userId}, "[SudoMode] error getting user"
+				return next(err)
+			if !userRecord?
+				err = new Error('user not found')
+				logger.err {err, userId}, "[SudoMode] user not found"
+				return next(err)
+			AuthenticationManager.authenticate email: userRecord.email, password, (err, user) ->
+				if err?
+					logger.err {err, userId}, "[SudoMode] error authenticating user"
+					return next(err)
+				if user?
+					logger.log {userId}, "[SudoMode] authenticated user, activating sudo mode"
+					SudoModeHandler.activateSudoMode userId, (err) ->
+						if err?
+							logger.err {err, userId}, "[SudoMode] error activating sudo mode"
+							return next(err)
+						return res.json {
+							redir: redir
+						}
+				else
+					logger.log {userId}, "[SudoMode] authentication failed for user"
+					return res.json {
+						message: {
+							text: req.i18n.translate("invalid_password"),
+							type: 'error'
+						}
+					}
+

+ 33 - 0
services/web/app/coffee/Features/SudoMode/SudoModeHandler.coffee

@@ -0,0 +1,33 @@
+RedisWrapper = require('../../infrastructure/RedisWrapper')
+rclient = RedisWrapper.client('sudomode')
+logger = require('logger-sharelatex')
+
+
+TIMEOUT_IN_SECONDS = 60 * 60
+
+
+module.exports = SudoModeHandler =
+
+	_buildKey: (userId) ->
+		"SudoMode:{#{userId}}"
+
+	activateSudoMode: (userId, callback=(err)->) ->
+		if !userId?
+			return callback(new Error('[SudoMode] user must be supplied'))
+		duration = TIMEOUT_IN_SECONDS
+		logger.log {userId, duration}, "[SudoMode] activating sudo mode for user"
+		rclient.set SudoModeHandler._buildKey(userId), '1', 'EX', duration, callback
+
+	clearSudoMode: (userId, callback=(err)->) ->
+		if !userId?
+			return callback(new Error('[SudoMode] user must be supplied'))
+		logger.log {userId}, "[SudoMode] clearing sudo mode for user"
+		rclient.del SudoModeHandler._buildKey(userId), callback
+
+	isSudoModeActive: (userId, callback=(err, isActive)->) ->
+		if !userId?
+			return callback(new Error('[SudoMode] user must be supplied'))
+		rclient.get SudoModeHandler._buildKey(userId), (err, result) ->
+			if err?
+				return callback(err)
+			callback(null, result == '1')

+ 24 - 0
services/web/app/coffee/Features/SudoMode/SudoModeMiddlewear.coffee

@@ -0,0 +1,24 @@
+logger = require 'logger-sharelatex'
+SudoModeHandler = require './SudoModeHandler'
+AuthenticationController = require '../Authentication/AuthenticationController'
+
+
+module.exports = SudoModeMiddlewear =
+
+	protectPage: (req, res, next) ->
+		if req.externalAuthenticationSystemUsed()
+			logger.log {userId}, "[SudoMode] using external auth, skipping sudo-mode check"
+			return next()
+		userId = AuthenticationController.getLoggedInUserId(req)
+		logger.log {userId}, "[SudoMode] protecting endpoint, checking if sudo mode is active"
+		SudoModeHandler.isSudoModeActive userId, (err, isActive) ->
+			if err?
+				logger.err {err, userId}, "[SudoMode] error checking if sudo mode is active"
+				return next(err)
+			if isActive
+				logger.log {userId}, "[SudoMode] sudo mode active, continuing"
+				return next()
+			else
+				logger.log {userId}, "[SudoMode] sudo mode not active, redirecting"
+				AuthenticationController._setRedirectInSession(req)
+				return res.redirect('/confirm-password')

+ 2 - 0
services/web/app/coffee/Features/User/UserController.coffee

@@ -11,6 +11,7 @@ AuthenticationManager = require("../Authentication/AuthenticationManager")
 AuthenticationController = require('../Authentication/AuthenticationController')
 UserSessionsManager = require("./UserSessionsManager")
 UserUpdater = require("./UserUpdater")
+SudoModeHandler = require('../SudoMode/SudoModeHandler')
 settings = require "settings-sharelatex"
 
 module.exports = UserController =
@@ -118,6 +119,7 @@ module.exports = UserController =
 			if err
 				logger.err err: err, 'error destorying session'
 			UserSessionsManager.untrackSession(user, sessionId)
+			SudoModeHandler.clearSudoMode(user._id)
 			res.redirect '/login'
 
 	register : (req, res, next = (error) ->)->

+ 5 - 5
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -84,6 +84,11 @@ module.exports = (app, webRouter, apiRouter)->
 	webRouter.use addSetContentDisposition
 	apiRouter.use addSetContentDisposition
 
+	webRouter.use (req, res, next)->
+		req.externalAuthenticationSystemUsed = res.locals.externalAuthenticationSystemUsed = ->
+			Settings.ldap? or Settings.saml?
+		next()
+
 	webRouter.use (req, res, next)->
 
 		cdnBlocked = req.query.nocdn == 'true' or req.session.cdnBlocked
@@ -222,11 +227,6 @@ module.exports = (app, webRouter, apiRouter)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()
 
-	webRouter.use (req, res, next)->
-		res.locals.externalAuthenticationSystemUsed = ->
-			Settings.ldap? or Settings.saml?
-		next()
-
 	webRouter.use (req, res, next)->
 		currentUser = AuthenticationController.getSessionUser(req)
 		if currentUser?

+ 13 - 2
services/web/app/coffee/router.coffee

@@ -39,6 +39,8 @@ ContactRouter = require("./Features/Contacts/ContactRouter")
 ReferencesController = require('./Features/References/ReferencesController')
 AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
 BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
+SudoModeController = require('./Features/SudoMode/SudoModeController')
+SudoModeMiddlewear = require('./Features/SudoMode/SudoModeMiddlewear')
 AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
 AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
 
@@ -86,11 +88,17 @@ module.exports = class Router
 		webRouter.get '/user/activate', UserPagesController.activateAccountPage
 		AuthenticationController.addEndpointToLoginWhitelist '/user/activate'
 
-		webRouter.get  '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
+		webRouter.get  '/user/settings',
+			AuthenticationController.requireLogin(),
+			SudoModeMiddlewear.protectPage,
+			UserPagesController.settingsPage
 		webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
 		webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
 
-		webRouter.get  '/user/sessions', AuthenticationController.requireLogin(), UserPagesController.sessionsPage
+		webRouter.get  '/user/sessions',
+			AuthenticationController.requireLogin(),
+			SudoModeMiddlewear.protectPage,
+			UserPagesController.sessionsPage
 		webRouter.post '/user/sessions/clear', AuthenticationController.requireLogin(), UserController.clearSessions
 
 		webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
@@ -239,6 +247,9 @@ module.exports = class Router
 		webRouter.get "/beta/participate",  AuthenticationController.requireLogin(), BetaProgramController.optInPage
 		webRouter.post "/beta/opt-in", AuthenticationController.requireLogin(), BetaProgramController.optIn
 		webRouter.post "/beta/opt-out", AuthenticationController.requireLogin(), BetaProgramController.optOut
+		webRouter.get "/confirm-password", AuthenticationController.requireLogin(), SudoModeController.sudoModePrompt
+		webRouter.post "/confirm-password", AuthenticationController.requireLogin(), SudoModeController.submitPassword
+
 
 		#Admin Stuff
 		webRouter.get  '/admin', AuthorizationMiddlewear.ensureUserIsSiteAdmin, AdminController.index

+ 45 - 0
services/web/app/views/sudo_mode/sudo_mode_prompt.pug

@@ -0,0 +1,45 @@
+extends ../layout
+
+block content
+	.content.content-alt
+		.container
+			.row
+				.col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3
+					.card
+						.page-header.text-centered
+							h2 #{translate('confirm_password_to_continue')}
+						div
+							.container-fluid
+								.row
+									.col-md-12
+										form(async-form="confirmPassword", name="confirmPassword",
+												action='/confirm-password', method="POST", ng-cloak)
+											input(name='_csrf', type='hidden', value=csrfToken)
+											form-messages(for="confirmPassword")
+											.form-group
+												label
+													| #{translate('password')}
+												input.form-control(
+													type='password',
+													name='password',
+													required,
+													placeholder='********',
+													ng-model="password"
+												)
+												span.small.text-primary(
+													ng-show="confirmPassword.password.$invalid && confirmPassword.password.$dirty"
+												)
+													| #{translate("required")}
+											.actions
+												button.btn-primary.btn(
+													style="width: 100%",
+													type='submit',
+													ng-disabled="confirmPassword.inflight"
+												)
+													span #{translate("confirm")}
+
+								.row.row-spaced-small
+									.col-md-12
+										p.text-centered
+											small
+												| #{translate('confirm_password_footer')}

+ 297 - 0
services/web/test/UnitTests/coffee/SudoMode/SudoModeControllerTests.coffee

@@ -0,0 +1,297 @@
+SandboxedModule = require('sandboxed-module')
+sinon = require 'sinon'
+should = require("chai").should()
+expect = require('chai').expect
+MockRequest = require "../helpers/MockRequest"
+MockResponse = require "../helpers/MockResponse"
+modulePath = '../../../../app/js/Features/SudoMode/SudoModeController'
+
+describe 'SudoModeController', ->
+	beforeEach ->
+		@user =
+			_id: 'abcd'
+			email: 'user@example.com'
+		@UserGetter =
+			getUser: sinon.stub().callsArgWith(2, null, @user)
+		@SudoModeHandler =
+			isSudoModeActive: sinon.stub()
+			activateSudoMode: sinon.stub()
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@user._id)
+			_getRediretFromSession: sinon.stub()
+		@AuthenticationManager =
+			authenticate: sinon.stub()
+		@UserGetter =
+			getUser: sinon.stub()
+		@SudoModeController = SandboxedModule.require modulePath, requires:
+			'logger-sharelatex': {log: sinon.stub(), err: sinon.stub()}
+			'./SudoModeHandler': @SudoModeHandler
+			'../Authentication/AuthenticationController': @AuthenticationController
+			'../Authentication/AuthenticationManager': @AuthenticationManager
+			'../../infrastructure/Mongoose': {mongo: {ObjectId: () -> 'some_object_id'}}
+			'../User/UserGetter': @UserGetter
+
+	describe 'sudoModePrompt', ->
+		beforeEach ->
+			@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, false)
+			@req = {externalAuthenticationSystemUsed: sinon.stub().returns(false)}
+			@res = {redirect: sinon.stub(), render: sinon.stub()}
+			@next = sinon.stub()
+
+		it 'should get the logged in user id', ->
+			@SudoModeController.sudoModePrompt(@req, @res, @next)
+			@AuthenticationController.getLoggedInUserId.callCount.should.equal 1
+			@AuthenticationController.getLoggedInUserId.calledWith(@req).should.equal true
+
+		it 'should check if sudo-mode is active', ->
+			@SudoModeController.sudoModePrompt(@req, @res, @next)
+			@SudoModeHandler.isSudoModeActive.callCount.should.equal 1
+			@SudoModeHandler.isSudoModeActive.calledWith(@user._id).should.equal true
+
+		it 'should redirect when sudo-mode is active', ->
+			@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, true)
+			@SudoModeController.sudoModePrompt(@req, @res, @next)
+			@res.redirect.callCount.should.equal 1
+			@res.redirect.calledWith('/project').should.equal true
+
+		it 'should render the sudo_mode_prompt page when sudo mode is not active', ->
+			@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, false)
+			@SudoModeController.sudoModePrompt(@req, @res, @next)
+			@res.render.callCount.should.equal 1
+			@res.render.calledWith('sudo_mode/sudo_mode_prompt').should.equal true
+
+		describe 'when isSudoModeActive produces an error', ->
+			beforeEach ->
+				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, new Error('woops'))
+				@next = sinon.stub()
+
+			it 'should call next with an error', ->
+				@SudoModeController.sudoModePrompt(@req, @res, @next)
+				@next.callCount.should.equal 1
+				expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+			it 'should not render page', ->
+				@SudoModeController.sudoModePrompt(@req, @res, @next)
+				@res.render.callCount.should.equal 0
+
+		describe 'when external auth system is used', ->
+			beforeEach ->
+				@req.externalAuthenticationSystemUsed = sinon.stub().returns(true)
+
+			it 'should redirect', ->
+				@SudoModeController.sudoModePrompt(@req, @res, @next)
+				@res.redirect.callCount.should.equal 1
+				@res.redirect.calledWith('/project').should.equal true
+
+			it 'should not check if sudo mode is active', ->
+				@SudoModeController.sudoModePrompt(@req, @res, @next)
+				@SudoModeHandler.isSudoModeActive.callCount.should.equal 0
+
+			it 'should not render page', ->
+				@SudoModeController.sudoModePrompt(@req, @res, @next)
+				@res.render.callCount.should.equal 0
+
+	describe 'submitPassword', ->
+		beforeEach ->
+			@AuthenticationController._getRedirectFromSession = sinon.stub().returns '/somewhere'
+			@UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user)
+			@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, @user)
+			@SudoModeHandler.activateSudoMode = sinon.stub().callsArgWith(1, null)
+			@password = 'a_terrible_secret'
+			@req = {body: {password: @password}}
+			@res = {json: sinon.stub()}
+			@next = sinon.stub()
+
+		describe 'when all goes well', ->
+			beforeEach ->
+
+			it 'should get the logged in user id', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@AuthenticationController.getLoggedInUserId.callCount.should.equal 1
+				@AuthenticationController.getLoggedInUserId.calledWith(@req).should.equal true
+
+			it 'should get redirect from session', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@AuthenticationController._getRedirectFromSession.callCount.should.equal 1
+				@AuthenticationController._getRedirectFromSession.calledWith(@req).should.equal true
+
+			it 'should get the user from storage', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@UserGetter.getUser.callCount.should.equal 1
+				@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+			it 'should try to authenticate the user with the password', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@AuthenticationManager.authenticate.callCount.should.equal 1
+				@AuthenticationManager.authenticate.calledWith({email: @user.email}, @password).should.equal true
+
+			it 'should activate sudo mode', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@SudoModeHandler.activateSudoMode.callCount.should.equal 1
+				@SudoModeHandler.activateSudoMode.calledWith(@user._id).should.equal true
+
+			it 'should send back a json response', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@res.json.callCount.should.equal 1
+				@res.json.calledWith({redir: '/somewhere'}).should.equal true
+
+			it 'should not call next', ->
+				@SudoModeController.submitPassword(@req, @res, @next)
+				@next.callCount.should.equal 0
+
+			describe 'when no password is supplied', ->
+				beforeEach ->
+					@req.body.password = ''
+					@next = sinon.stub()
+
+				it 'should return next with an error', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+				it 'should not get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 0
+
+				it 'should not try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 0
+
+				it 'should not activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 0
+
+				it 'should not send back a json response', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@res.json.callCount.should.equal 0
+
+			describe 'when getUser produces an error', ->
+				beforeEach ->
+					@UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops'))
+					@next = sinon.stub()
+
+				it 'should return next with an error', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+				it 'should get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+				it 'should not try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 0
+
+				it 'should not activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 0
+
+				it 'should not send back a json response', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@res.json.callCount.should.equal 0
+
+			describe 'when getUser does not find a user', ->
+				beforeEach ->
+					@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
+					@next = sinon.stub()
+
+				it 'should return next with an error', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+				it 'should get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+				it 'should not try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 0
+
+				it 'should not activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 0
+
+				it 'should not send back a json response', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@res.json.callCount.should.equal 0
+
+			describe 'when authentication fails', ->
+				beforeEach ->
+					@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, null)
+					@res.json = sinon.stub()
+					@req.i18n = {translate: sinon.stub()}
+
+				it 'should send back a failure message', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@res.json.callCount.should.equal 1
+					expect(@res.json.lastCall.args[0]).to.have.keys ['message']
+					expect(@res.json.lastCall.args[0].message).to.have.keys ['text', 'type']
+					@req.i18n.translate.callCount.should.equal 1
+					@req.i18n.translate.calledWith('invalid_password')
+
+				it 'should get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+				it 'should try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 1
+					@AuthenticationManager.authenticate.calledWith({email: @user.email}, @password).should.equal true
+
+				it 'should not activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 0
+
+			describe 'when authentication produces an error', ->
+				beforeEach ->
+					@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, new Error('woops'))
+					@next = sinon.stub()
+
+				it 'should return next with an error', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+				it 'should get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+				it 'should try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 1
+					@AuthenticationManager.authenticate.calledWith({email: @user.email}, @password).should.equal true
+
+				it 'should not activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 0
+
+			describe 'when sudo mode activation produces an error', ->
+				beforeEach ->
+					@SudoModeHandler.activateSudoMode = sinon.stub().callsArgWith(1, new Error('woops'))
+					@next = sinon.stub()
+
+				it 'should return next with an error', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+
+				it 'should get the user from storage', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@UserGetter.getUser.callCount.should.equal 1
+					@UserGetter.getUser.calledWith('some_object_id', {email: 1}).should.equal true
+
+				it 'should try to authenticate the user with the password', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@AuthenticationManager.authenticate.callCount.should.equal 1
+					@AuthenticationManager.authenticate.calledWith({email: @user.email}, @password).should.equal true
+
+				it 'should have tried to activate sudo mode', ->
+					@SudoModeController.submitPassword(@req, @res, @next)
+					@SudoModeHandler.activateSudoMode.callCount.should.equal 1
+					@SudoModeHandler.activateSudoMode.calledWith(@user._id).should.equal true

+ 188 - 0
services/web/test/UnitTests/coffee/SudoMode/SudoModeHandlerTests.coffee

@@ -0,0 +1,188 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+expect = require('chai').expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/SudoMode/SudoModeHandler'
+
+
+describe 'SudoModeHandler', ->
+	beforeEach ->
+		@userId = 'some_user_id'
+		@rclient = {get: sinon.stub(), set: sinon.stub(), del: sinon.stub()}
+		@RedisWrapper =
+			client: () => @rclient
+		@SudoModeHandler = SandboxedModule.require modulePath, requires:
+			'../../infrastructure/RedisWrapper': @RedisWrapper
+			'logger-sharelatex': @logger = {log: sinon.stub(), err: sinon.stub()}
+
+	describe '_buildKey', ->
+
+		it 'should build a properly formed key', ->
+			expect(@SudoModeHandler._buildKey('123')).to.equal 'SudoMode:{123}'
+
+	describe 'activateSudoMode', ->
+		beforeEach ->
+			@call = (cb) =>
+				@SudoModeHandler.activateSudoMode @userId, cb
+
+		describe 'when all goes well', ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(4, null)
+
+
+			it 'should not produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.equal null
+					done()
+
+			it 'should set a value in redis', (done) ->
+				@call (err) =>
+					expect(@rclient.set.callCount).to.equal 1
+					expect(@rclient.set.calledWith(
+						'SudoMode:{some_user_id}', '1', 'EX', 60*60
+					)).to.equal true
+					done()
+
+		describe 'when user id is not supplied', ->
+			beforeEach ->
+				@call = (cb) =>
+					@SudoModeHandler.activateSudoMode null, cb
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not set value in redis', (done) ->
+				@call (err) =>
+					expect(@rclient.set.callCount).to.equal 0
+					done()
+
+		describe 'when rclient.set produces an error', ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(4, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+	describe 'clearSudoMode', ->
+		beforeEach ->
+			@rclient.del = sinon.stub().callsArgWith(1, null)
+			@call = (cb) =>
+				@SudoModeHandler.clearSudoMode @userId, cb
+
+		it 'should not produce an error', (done) ->
+			@call (err) =>
+				expect(err).to.equal null
+				done()
+
+		it 'should delete key from redis', (done) ->
+			@call (err) =>
+				expect(@rclient.del.callCount).to.equal 1
+				expect(@rclient.del.calledWith(
+					'SudoMode:{some_user_id}'
+				)).to.equal true
+				done()
+
+		describe 'when rclient.del produces an error', ->
+			beforeEach ->
+				@rclient.del = sinon.stub().callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+		describe 'when user id is not supplied', ->
+			beforeEach ->
+				@call = (cb) =>
+					@SudoModeHandler.clearSudoMode null, cb
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not delete value in redis', (done) ->
+				@call (err) =>
+					expect(@rclient.del.callCount).to.equal 0
+					done()
+
+	describe 'isSudoModeActive', ->
+		beforeEach ->
+			@call = (cb) =>
+				@SudoModeHandler.isSudoModeActive @userId, cb
+
+		describe 'when sudo-mode is active for that user', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, null, '1')
+
+			it 'should not produce an error', (done) ->
+				@call (err, isActive) =>
+					expect(err).to.equal null
+					done()
+
+			it 'should get the value from redis', (done) ->
+				@call (err, isActive) =>
+					expect(@rclient.get.callCount).to.equal 1
+					expect(@rclient.get.calledWith('SudoMode:{some_user_id}')).to.equal true
+					done()
+
+			it 'should produce a true result', (done) ->
+				@call (err, isActive) =>
+					expect(isActive).to.equal true
+					done()
+
+		describe 'when sudo-mode is not active for that user', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, null, null)
+
+			it 'should not produce an error', (done) ->
+				@call (err, isActive) =>
+					expect(err).to.equal null
+					done()
+
+			it 'should get the value from redis', (done) ->
+				@call (err, isActive) =>
+					expect(@rclient.get.callCount).to.equal 1
+					expect(@rclient.get.calledWith('SudoMode:{some_user_id}')).to.equal true
+					done()
+
+			it 'should produce a false result', (done) ->
+				@call (err, isActive) =>
+					expect(isActive).to.equal false
+					done()
+
+		describe 'when rclient.get produces an error', ->
+			beforeEach ->
+				@rclient.get = sinon.stub().callsArgWith(1, new Error('woops'))
+
+			it 'should produce an error', (done) ->
+				@call (err, isActive) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					expect(isActive).to.be.oneOf [null, undefined]
+					done()
+
+		describe 'when user id is not supplied', ->
+			beforeEach ->
+				@call = (cb) =>
+					@SudoModeHandler.isSudoModeActive null, cb
+
+			it 'should produce an error', (done) ->
+				@call (err) =>
+					expect(err).to.not.equal null
+					expect(err).to.be.instanceof Error
+					done()
+
+			it 'should not get value in redis', (done) ->
+				@call (err) =>
+					expect(@rclient.get.callCount).to.equal 0
+					done()

+ 123 - 0
services/web/test/UnitTests/coffee/SudoMode/SudoModeMiddlewearTests.coffee

@@ -0,0 +1,123 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+expect = require('chai').expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/SudoMode/SudoModeMiddlewear'
+
+
+describe 'SudoModeMiddlewear', ->
+	beforeEach ->
+		@userId = 'some_user_id'
+		@SudoModeHandler =
+			isSudoModeActive: sinon.stub()
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@userId)
+			_setRedirectInSession: sinon.stub()
+		@SudoModeMiddlewear = SandboxedModule.require modulePath, requires:
+			'./SudoModeHandler': @SudoModeHandler
+			'../Authentication/AuthenticationController': @AuthenticationController
+			'logger-sharelatex': {log: sinon.stub(), err: sinon.stub()}
+
+	describe 'protectPage', ->
+		beforeEach ->
+			@externalAuth = false
+			@call = (cb) =>
+				@req = {externalAuthenticationSystemUsed: sinon.stub().returns(@externalAuth)}
+				@res = {redirect: sinon.stub()}
+				@next = sinon.stub()
+				@SudoModeMiddlewear.protectPage @req, @res, @next
+				cb()
+
+		describe 'when sudo mode is active', ->
+			beforeEach ->
+				@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId)
+				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, true)
+
+			it 'should get the current user id', (done) ->
+				@call () =>
+					@AuthenticationController.getLoggedInUserId.callCount.should.equal 1
+					done()
+
+			it 'should check if sudo-mode is active', (done) ->
+				@call () =>
+					@SudoModeHandler.isSudoModeActive.callCount.should.equal 1
+					@SudoModeHandler.isSudoModeActive.calledWith(@userId).should.equal true
+					done()
+
+			it 'should call next', (done) ->
+				@call () =>
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.equal undefined
+					done()
+
+		describe 'when sudo mode is not active', ->
+			beforeEach ->
+				@AuthenticationController._setRedirectInSession = sinon.stub()
+				@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId)
+				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, false)
+
+			it 'should get the current user id', (done) ->
+				@call () =>
+					@AuthenticationController.getLoggedInUserId.callCount.should.equal 1
+					done()
+
+			it 'should check if sudo-mode is active', (done) ->
+				@call () =>
+					@SudoModeHandler.isSudoModeActive.callCount.should.equal 1
+					@SudoModeHandler.isSudoModeActive.calledWith(@userId).should.equal true
+					done()
+
+			it 'should set redirect in session', (done) ->
+				@call () =>
+					@AuthenticationController._setRedirectInSession.callCount.should.equal 1
+					@AuthenticationController._setRedirectInSession.calledWith(@req).should.equal true
+					done()
+
+			it 'should redirect to the password-prompt page', (done) ->
+				@call () =>
+					@res.redirect.callCount.should.equal 1
+					@res.redirect.calledWith('/confirm-password').should.equal true
+					done()
+
+		describe 'when isSudoModeActive produces an error', ->
+			beforeEach ->
+				@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId)
+				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, new Error('woops'))
+
+			it 'should get the current user id', (done) ->
+				@call () =>
+					@AuthenticationController.getLoggedInUserId.callCount.should.equal 1
+					done()
+
+			it 'should check if sudo-mode is active', (done) ->
+				@call () =>
+					@SudoModeHandler.isSudoModeActive.callCount.should.equal 1
+					@SudoModeHandler.isSudoModeActive.calledWith(@userId).should.equal true
+					done()
+
+			it 'should call next with an error', (done) ->
+				@call () =>
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.be.instanceof Error
+					done()
+
+		describe 'when external auth is being used', ->
+			beforeEach ->
+				@externalAuth = true
+
+			it 'should immediately return next with no args', (done) ->
+				@call () =>
+					@next.callCount.should.equal 1
+					expect(@next.lastCall.args[0]).to.not.exist
+					done()
+
+			it 'should not get the current user id', (done) ->
+				@call () =>
+					@AuthenticationController.getLoggedInUserId.callCount.should.equal 0
+					done()
+
+			it 'should not check if sudo-mode is active', (done) ->
+				@call () =>
+					@SudoModeHandler.isSudoModeActive.callCount.should.equal 0
+					done()

+ 14 - 0
services/web/test/UnitTests/coffee/User/UserControllerTests.coffee

@@ -60,6 +60,8 @@ describe "UserController", ->
 			trackSession: sinon.stub()
 			untrackSession: sinon.stub()
 			revokeAllUserSessions: sinon.stub().callsArgWith(2, null)
+		@SudoModeHandler =
+			clearSudoMode: sinon.stub()
 		@UserController = SandboxedModule.require modulePath, requires:
 			"./UserLocator": @UserLocator
 			"./UserDeleter": @UserDeleter
@@ -73,6 +75,7 @@ describe "UserController", ->
 			"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
 			"./UserHandler":@UserHandler
 			"./UserSessionsManager": @UserSessionsManager
+			"../SudoMode/SudoModeHandler": @SudoModeHandler
 			"settings-sharelatex": @settings
 			"logger-sharelatex":
 				log:->
@@ -302,6 +305,17 @@ describe "UserController", ->
 
 			@UserController.logout @req, @res
 
+		it 'should clear sudo-mode', (done) ->
+			@req.session.destroy = sinon.stub().callsArgWith(0)
+			@SudoModeHandler.clearSudoMode = sinon.stub()
+			@res.redirect = (url)=>
+				url.should.equal "/login"
+				@SudoModeHandler.clearSudoMode.callCount.should.equal 1
+				@SudoModeHandler.clearSudoMode.calledWith(@user._id).should.equal true
+				done()
+
+			@UserController.logout @req, @res
+
 
 	describe "register", ->
 		beforeEach ->

+ 33 - 20
services/web/test/acceptance/coffee/SessionTests.coffee

@@ -34,9 +34,9 @@ describe "Sessions", ->
 							expect(sessions[0].slice(0, 5)).to.equal 'sess:'
 							next()
 
-					# should be able to access settings page
+					# should be able to access project list page
 					, (next) =>
-						@user1.getUserSettingsPage (err, statusCode) =>
+						@user1.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()
@@ -94,15 +94,15 @@ describe "Sessions", ->
 							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
 							next()
 
-					# both should be able to access settings page
+					# both should be able to access project list page
 					, (next) =>
-						@user1.getUserSettingsPage (err, statusCode) =>
+						@user1.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()
 
 					, (next) =>
-						@user2.getUserSettingsPage (err, statusCode) =>
+						@user2.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()
@@ -117,16 +117,16 @@ describe "Sessions", ->
 							expect(sessions.length).to.equal 1
 							next()
 
-					# first session should not have access to settings page
+					# first session should not have access to project list page
 					, (next) =>
-						@user1.getUserSettingsPage (err, statusCode) =>
+						@user1.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
 
 					# second session should still have access to settings
 					, (next) =>
-						@user2.getUserSettingsPage (err, statusCode) =>
+						@user2.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()
@@ -141,9 +141,9 @@ describe "Sessions", ->
 							expect(sessions.length).to.equal 0
 							next()
 
-					# second session should not have access to settings page
+					# second session should not have access to project list page
 					, (next) =>
-						@user2.getUserSettingsPage (err, statusCode) =>
+						@user2.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
@@ -216,22 +216,22 @@ describe "Sessions", ->
 							expect(sessions.length).to.equal 1
 							next()
 
-					# users one and three should not be able to access settings page
+					# users one and three should not be able to access project list page
 					, (next) =>
-						@user1.getUserSettingsPage (err, statusCode) =>
+						@user1.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
 
 					, (next) =>
-						@user3.getUserSettingsPage (err, statusCode) =>
+						@user3.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
 
-					# user two should still be logged in, and able to access settings page
+					# user two should still be logged in, and able to access project list page
 					, (next) =>
-						@user2.getUserSettingsPage (err, statusCode) =>
+						@user2.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()
@@ -305,6 +305,19 @@ describe "Sessions", ->
 							expect(sessions[1].slice(0, 5)).to.equal 'sess:'
 							next()
 
+					# enter sudo-mode
+					, (next) =>
+						@user2.getCsrfToken (err) =>
+							expect(err).to.be.oneOf [null, undefined]
+							@user2.request.post {
+								uri: '/confirm-password',
+								json:
+									password: @user2.password
+							}, (err, response, body) =>
+								expect(err).to.be.oneOf [null, undefined]
+								expect(response.statusCode).to.equal 200
+								next()
+
 					# check the sessions page
 					, (next) =>
 						@user2.request.get {
@@ -328,22 +341,22 @@ describe "Sessions", ->
 							expect(sessions.length).to.equal 1
 							next()
 
-					# users one and three should not be able to access settings page
+					# users one and three should not be able to access project list page
 					, (next) =>
-						@user1.getUserSettingsPage (err, statusCode) =>
+						@user1.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
 
 					, (next) =>
-						@user3.getUserSettingsPage (err, statusCode) =>
+						@user3.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 302
 							next()
 
-					# user two should still be logged in, and able to access settings page
+					# user two should still be logged in, and able to access project list page
 					, (next) =>
-						@user2.getUserSettingsPage (err, statusCode) =>
+						@user2.getProjectListPage (err, statusCode) =>
 							expect(err).to.equal null
 							expect(statusCode).to.equal 200
 							next()

+ 9 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -134,6 +134,15 @@ class User
 				return callback(error) if error?
 				callback(null, response.statusCode)
 
+	getProjectListPage: (callback=(error, statusCode)->) ->
+		@getCsrfToken (error) =>
+			return callback(error) if error?
+			@request.get {
+				url: "/project"
+			}, (error, response, body) =>
+				return callback(error) if error?
+				callback(null, response.statusCode)
+
 
 
 module.exports = User