Add return-to-admin (turn off espionage mode)

This commit is contained in:
Scott Erickson 2016-03-03 14:22:50 -08:00
parent 59e8c42ddb
commit a2249f8df1
15 changed files with 212 additions and 52 deletions

View file

@ -81,6 +81,7 @@
// Placeholder for iPad, which inspects the user object at the bottom of an injected page.
window.serverConfig = "serverConfigTag";
window.userObject = "userObjectTag";
window.amActually = "amActuallyTag";
window.me = {
get: function(attribute) { return window.userObject[attribute]; }
}

View file

@ -102,3 +102,12 @@ module.exports.updateSelects = (el) ->
module.exports.validateEmail = (email) ->
filter = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i # https://news.ycombinator.com/item?id=5763990
return filter.test(email)
module.exports.disableSubmit = (el, message='...') ->
$el = $(el)
$el.data('original-text', $el.text())
$el.text(message).attr('disabled', true)
module.exports.enableSubmit = (el) ->
$el = $(el)
$el.text($el.data('original-text')).attr('disabled', false)

View file

@ -180,6 +180,19 @@ module.exports = class User extends CocoModel
isOnPremiumServer: ->
me.get('country') in ['china', 'brazil']
spy: (user, options={}) ->
user = user.id or user # User instance, user ID, email or username
options.url = '/auth/spy'
options.type = 'POST'
options.data ?= {}
options.data.user = user
@fetch(options)
stopSpying: (options={}) ->
options.url = '/auth/stop-spying'
options.type = 'POST'
@fetch(options)
tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96,
5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15

View file

@ -3,7 +3,7 @@ extends /templates/base
block content
.form-horizontal
.form-group
form#espionage-form.form-group
label.control-label.col-sm-2(for="espionage-name-or-email" data-i18n="admin.av_espionage") Espionage
.col-sm-4
input.form-control#espionage-name-or-email(data-i18n="[placeholder]admin.av_espionage_placeholder", placeholder="Email or username", type="text")
@ -11,7 +11,12 @@ block content
button.btn.btn-primary.btn-large#enter-espionage-mode 007
label.control-label.col-sm-5(for="espionage-name-or-email")
em you are currently #{me.get('name')} at #{me.get('email')}
.form-group
if view.amActually
br
em but you are actually #{view.amActually.get('name')} at #{view.amActually.get('email')}
br
button#stop-spying-btn.btn.btn-xs Stop Spying
form#user-search-form.form-group
label.control-label.col-sm-2(for="user-search" data-i18n="admin.av_usersearch") User Search
.col-sm-4
input.form-control#user-search(data-i18n="[placeholder]admin.av_usersearch_placeholder", placeholder="Email, username, name, whatever", type="text")

View file

@ -1,7 +1,10 @@
{backboneFailure, genericFailure} = require 'core/errors'
errors = require 'core/errors'
RootView = require 'views/core/RootView'
template = require 'templates/admin'
AdministerUserModal = require 'views/admin/AdministerUserModal'
forms = require 'core/forms'
User = require 'models/User'
module.exports = class MainAdminView extends RootView
id: 'admin-view'
@ -9,38 +12,48 @@ module.exports = class MainAdminView extends RootView
lastUserSearchValue: ''
events:
'keyup': 'checkForFormSubmissionEnterPress'
'click #enter-espionage-mode': 'enterEspionageMode'
'click #user-search-button': 'searchForUser'
'submit #espionage-form': 'onSubmitEspionageForm'
'submit #user-search-form': 'onSubmitUserSearchForm'
'click #stop-spying-btn': 'onClickStopSpyingButton'
'click #increment-button': 'incrementUserAttribute'
'click #user-search-result': 'onClickUserSearchResult'
'click #create-free-sub-btn': 'onClickFreeSubLink'
'click #terminal-create': 'onClickTerminalSubLink'
initialize: ->
if window.amActually
@amActually = new User({_id: window.amActually})
@amActually.fetch()
@supermodel.trackModel(@amActually)
checkForFormSubmissionEnterPress: (e) ->
if e.which is 13 and @$el.find('#espionage-name-or-email').val() isnt ''
@enterEspionageMode()
return
if @$el.find('#user-search').val() isnt @lastUserSearchValue
@searchForUser()
onClickStopSpyingButton: ->
button = @$('#stop-spying-btn')
forms.disableSubmit(button)
me.stopSpying({
success: -> document.location.reload()
error: ->
forms.enableSubmit(button)
errors.showNotyNetworkError(arguments...)
})
enterEspionageMode: ->
onSubmitEspionageForm: (e) ->
e.preventDefault()
button = @$('#enter-espionage-mode')
userNameOrEmail = @$el.find('#espionage-name-or-email').val().toLowerCase()
$.ajax
type: 'POST',
url: '/auth/spy'
data: {nameOrEmailLower: userNameOrEmail}
success: @espionageSuccess
error: @espionageFailure
forms.disableSubmit(button)
me.spy(userNameOrEmail, {
success: -> window.location.reload()
error: ->
forms.enableSubmit(button)
errors.showNotyNetworkError(arguments...)
})
espionageSuccess: (model) ->
window.location.reload()
espionageFailure: (jqxhr, status, error)->
console.log "There was an error entering espionage mode: #{error}"
searchForUser: ->
return @onSearchRequestSuccess [] unless @lastUserSearchValue = @$el.find('#user-search').val().toLowerCase()
onSubmitUserSearchForm: (e) ->
e.preventDefault()
searchValue = @$el.find('#user-search').val()
return if searchValue is @lastUserSearchValue
return @onSearchRequestSuccess [] unless @lastUserSearchValue = searchValue.toLowerCase()
forms.disableSubmit(@$('#user-search-button'))
$.ajax
type: 'POST',
url: '/db/user/-/admin_search'
@ -49,6 +62,7 @@ module.exports = class MainAdminView extends RootView
error: @onSearchRequestFailure
onSearchRequestSuccess: (users) =>
forms.enableSubmit(@$('#user-search-button'))
result = ''
if users.length
result = ("<tr data-user-id='#{user._id}'><td><code>#{user._id}</code></td><td>#{_.escape(user.name or 'Anoner')}</td><td>#{_.escape(user.email)}</td></tr>" for user in users)
@ -57,6 +71,7 @@ module.exports = class MainAdminView extends RootView
onSearchRequestFailure: (jqxhr, status, error) =>
return if @destroyed
forms.enableSubmit(@$('#user-search-button'))
console.warn "There was an error looking up #{@lastUserSearchValue}:", error
incrementUserAttribute: (e) ->

View file

@ -34,6 +34,9 @@ module.exports = class RootView extends CocoView
subscriptions:
'achievements:new': 'handleNewAchievements'
'modal:open-modal-view': 'onOpenModalView'
shortcuts:
'ctrl+shift+a': 'navigateToAdmin'
showNewAchievement: (achievement, earnedAchievement) ->
earnedAchievement.set('notified', true)
@ -183,3 +186,7 @@ module.exports = class RootView extends CocoView
return false
logoutRedirectURL: '/'
navigateToAdmin: ->
if window.amActually or me.isAdmin()
application.router.navigate('/admin', {trigger: true})

View file

@ -1,6 +1,11 @@
# Middleware for both authentication and authorization
errors = require '../commons/errors'
utils = require '../lib/utils'
wrap = require 'co-express'
Promise = require 'bluebird'
mongoose = require 'mongoose'
User = require '../models/User'
module.exports = {
checkDocumentPermissions: (req, res, next) ->
@ -28,4 +33,34 @@ module.exports = {
return next new errors.Forbidden('You do not have permissions necessary.')
next()
spy: wrap (req, res) ->
throw new errors.Unauthorized('You must be logged in to enter espionage mode') unless req.user
throw new errors.Forbidden('You must be an admin to enter espionage mode') unless req.user.isAdmin()
user = req.body.user
throw new errors.UnprocessableEntity('Specify an id, username or email to espionage.') unless user
if utils.isID(user)
query = {_id: mongoose.Types.ObjectId(user)}
else
user = user.toLowerCase()
query = $or: [{nameLower: user}, {emailLower: user}]
user = yield User.findOne(query)
amActually = req.user
throw new errors.NotFound() unless user
req.loginAsync = Promise.promisify(req.login)
yield req.loginAsync user
req.session.amActually = amActually.id
res.status(200).send(user.toObject({req: req}))
stopSpying: wrap (req, res) ->
throw new errors.Unauthorized('You must be logged in to leave espionage mode') unless req.user
throw new errors.Forbidden('You must be in espionage mode to leave it') unless req.session.amActually
user = yield User.findById(req.session.amActually)
delete req.session.amActually
throw new errors.NotFound() unless user
req.loginAsync = Promise.promisify(req.login)
yield req.loginAsync user
res.status(200).send(user.toObject({req: req}))
}

View file

@ -0,0 +1,3 @@
# TODO: Migrate User to here
module.exports = require '../users/User'

View file

@ -44,21 +44,6 @@ module.exports.setup = (app) ->
)
))
app.post '/auth/spy', (req, res, next) ->
if req?.user?.isAdmin()
target = req.body.nameOrEmailLower
return errors.badInput res, 'Specify a username or email to espionage.' unless target
query = $or: [{nameLower: target}, {emailLower: target}]
User.findOne query, (err, user) ->
if err? then return errors.serverError res, 'There was an error finding the specified user'
unless user then return errors.badInput res, 'The specified user couldn\'t be found'
req.logIn user, (err) ->
if err? then return errors.serverError res, 'There was an error logging in with the specified user'
res.send(UserHandler.formatEntity(req, user))
return res.end()
else
return errors.unauthorized res, 'You must be an admin to enter espionage mode'
app.post('/auth/login', (req, res, next) ->
authentication.authenticate('local', (err, user, info) ->
return next(err) if err

View file

@ -1,6 +1,9 @@
mw = require '../middleware'
module.exports.setup = (app) ->
app.post('/auth/spy', mw.auth.spy)
app.post('/auth/stop-spying', mw.auth.stopSpying)
Article = require '../models/Article'
app.get('/db/article', mw.rest.get(Article))
app.post('/db/article', mw.auth.checkHasPermission(['admin', 'artisan']), mw.rest.post(Article))

View file

@ -335,6 +335,20 @@ UserSchema.statics.editableProperties = [
'heroConfig', 'iosIdentifierForVendor', 'siteref', 'referrer', 'schoolName', 'role'
]
UserSchema.statics.serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset', 'lastIP']
UserSchema.statics.candidateProperties = [ 'jobProfile', 'jobProfileApproved', 'jobProfileNotes']
UserSchema.set('toObject', {
transform: (doc, ret, options) ->
req = options.req
return ret unless req # TODO: Make deleting properties the default, but the consequences are far reaching
publicOnly = options.publicOnly
delete ret[prop] for prop in User.serverProperties
includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(doc._id) or req.session.amActually is doc.id))
delete ret[prop] for prop in User.privateProperties unless includePrivates
delete ret[prop] for prop in User.candidateProperties
return ret
})
UserSchema.plugin plugins.NamedPlugin
module.exports = User = mongoose.model('User', UserSchema)

View file

@ -45,13 +45,14 @@ UserHandler = class UserHandler extends Handler
props
formatEntity: (req, document, publicOnly=false) =>
# TODO: Delete. This function is duplicated in server User model toObject transform.
return null unless document?
obj = document.toObject()
delete obj[prop] for prop in serverProperties
includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(document._id)))
delete obj[prop] for prop in @privateProperties unless includePrivates
delete obj[prop] for prop in User.serverProperties
includePrivates = not publicOnly and (req.user and (req.user.isAdmin() or req.user._id.equals(document._id) or req.session.amActually is document.id))
delete obj[prop] for prop in User.privateProperties unless includePrivates
includeCandidate = not publicOnly and (includePrivates or (obj.jobProfile?.active and req.user and ('employer' in (req.user.get('permissions') ? [])) and @employerCanViewCandidate req.user, obj))
delete obj[prop] for prop in candidateProperties unless includeCandidate
delete obj[prop] for prop in User.candidateProperties unless includeCandidate
return obj
waterfallFunctions: [

View file

@ -85,10 +85,13 @@ setupExpressMiddleware = (app) ->
app.use(useragent.express())
app.use(express.favicon())
app.use(express.cookieParser(config.cookie_secret))
app.use(express.cookieParser())
app.use(express.bodyParser())
app.use(express.methodOverride())
app.use(express.cookieSession({secret:'defenestrate'}))
app.use(express.cookieSession({
key:'codecombat.sess'
secret:config.cookie_secret
}))
setupPassportMiddleware = (app) ->
app.use(authentication.initialize())
@ -180,6 +183,7 @@ setupFallbackRouteToIndex = (app) ->
production: config.isProduction
data = data.replace('"userObjectTag"', user)
data = data.replace('"amActuallyTag"', JSON.stringify(req.session.amActually))
res.header 'Cache-Control', 'no-cache, no-store, must-revalidate'
res.header 'Pragma', 'no-cache'
res.header 'Expires', 0

View file

@ -1,5 +1,8 @@
require '../common'
User = require '../../../server/users/User'
utils = require '../utils'
_ = require 'lodash'
Promise = require 'bluebird'
urlLogin = getURL('/auth/login')
urlReset = getURL('/auth/reset')
@ -199,3 +202,66 @@ describe '/auth/name', ->
expect(response.body.name).not.toBe 'joe'
expect(response.body.name.length).toBe 4 # 'joe' and a random number
done()
describe 'POST /auth/spy', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
@admin = yield utils.initAdmin()
@user1 = yield utils.initUser({name: 'Test User 1'})
@user2 = yield utils.initUser({name: 'Test User 2'})
done()
it 'logs in an admin as an arbitrary user', utils.wrap (done) ->
yield utils.loginUser(@admin)
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user1.id}}
expect(res.statusCode).toBe(200)
expect(body._id).toBe(@user1.id)
[res, body] = yield request.getAsync {uri: getURL('/auth/whoami'), json: true}
expect(body._id).toBe(@user1.id)
done()
it 'accepts the user\'s email as input', utils.wrap (done) ->
yield utils.loginUser(@admin)
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user1.get('email')}}
expect(res.statusCode).toBe(200)
expect(body._id).toBe(@user1.id)
done()
it 'accepts the user\'s username as input', utils.wrap (done) ->
yield utils.loginUser(@admin)
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user1.get('name')}}
expect(res.statusCode).toBe(200)
expect(body._id).toBe(@user1.id)
done()
it 'does not work for anonymous users', utils.wrap (done) ->
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user1.get('name')}}
expect(res.statusCode).toBe(401)
done()
it 'does not work for non-admins', utils.wrap (done) ->
yield utils.loginUser(@user1)
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user1.get('name')}}
expect(res.statusCode).toBe(403)
done()
fdescribe 'POST /auth/stop-spying', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User])
@admin = yield utils.initAdmin()
@user = yield utils.initUser()
yield utils.loginUser(@admin)
[res, body] = yield request.postAsync {uri: getURL('/auth/spy'), json: {user: @user.id}}
expect(res.statusCode).toBe(200)
done()
it 'it reverts the spying user back to the admin', utils.wrap (done) ->
[res, body] = yield request.getAsync {uri: getURL('/auth/whoami'), json: true}
expect(body._id).toBe(@user.id)
[res, body] = yield request.postAsync {uri: getURL('/auth/stop-spying'), json: true}
expect(res.statusCode).toBe(200)
expect(body._id).toBe(@admin.id)
[res, body] = yield request.getAsync {uri: getURL('/auth/whoami'), json: true}
expect(body._id).toBe(@admin.id)
done()

View file

@ -20,13 +20,12 @@ module.exports = mw =
if _.isFunction(options)
done = options
options = {}
options = _.extend({permissions: []}, options)
doc = {
options = _.extend({
permissions: []
email: 'user'+_.uniqueId()+'@gmail.com'
password: 'password'
permissions: options.permissions
}
user = new User(doc)
}, options)
user = new User(options)
promise = user.save()
return promise