mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-02 10:00:47 -05:00
Improve pre-registered account activation process
This commit is contained in:
parent
7a88afc953
commit
1e8ab5357b
13 changed files with 188 additions and 22 deletions
|
@ -12,9 +12,12 @@ basicAuth = require('basic-auth-connect')
|
||||||
|
|
||||||
module.exports = AuthenticationController =
|
module.exports = AuthenticationController =
|
||||||
login: (req, res, next = (error) ->) ->
|
login: (req, res, next = (error) ->) ->
|
||||||
email = req.body?.email?.toLowerCase()
|
AuthenticationController.doLogin req.body, req, res, next
|
||||||
password = req.body?.password
|
|
||||||
redir = Url.parse(req.body?.redir or "/project").path
|
doLogin: (options, req, res, next) ->
|
||||||
|
email = options.email?.toLowerCase()
|
||||||
|
password = options.password
|
||||||
|
redir = Url.parse(options.redir or "/project").path
|
||||||
LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
|
LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
|
||||||
if !isAllowed
|
if !isAllowed
|
||||||
logger.log email:email, "too many login requests"
|
logger.log email:email, "too many login requests"
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
PasswordResetHandler = require("./PasswordResetHandler")
|
PasswordResetHandler = require("./PasswordResetHandler")
|
||||||
RateLimiter = require("../../infrastructure/RateLimiter")
|
RateLimiter = require("../../infrastructure/RateLimiter")
|
||||||
|
AuthenticationController = require("../Authentication/AuthenticationController")
|
||||||
|
UserGetter = require("../User/UserGetter")
|
||||||
logger = require "logger-sharelatex"
|
logger = require "logger-sharelatex"
|
||||||
|
|
||||||
module.exports =
|
module.exports =
|
||||||
|
@ -37,14 +39,19 @@ module.exports =
|
||||||
title:"set_password"
|
title:"set_password"
|
||||||
passwordResetToken: req.session.resetToken
|
passwordResetToken: req.session.resetToken
|
||||||
|
|
||||||
setNewUserPassword: (req, res)->
|
setNewUserPassword: (req, res, next)->
|
||||||
{passwordResetToken, password} = req.body
|
{passwordResetToken, password} = req.body
|
||||||
if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
|
if !password? or password.length == 0 or !passwordResetToken? or passwordResetToken.length == 0
|
||||||
return res.sendStatus 400
|
return res.sendStatus 400
|
||||||
delete req.session.resetToken
|
delete req.session.resetToken
|
||||||
PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found) ->
|
PasswordResetHandler.setNewUserPassword passwordResetToken?.trim(), password?.trim(), (err, found, user_id) ->
|
||||||
return next(err) if err?
|
return next(err) if err?
|
||||||
if found
|
if found
|
||||||
res.sendStatus 200
|
if req.body.login_after
|
||||||
|
UserGetter.getUser user_id, {email: 1}, (err, user) ->
|
||||||
|
return next(err) if err?
|
||||||
|
AuthenticationController.doLogin {email:user.email, password: password}, req, res, next
|
||||||
|
else
|
||||||
|
res.sendStatus 200
|
||||||
else
|
else
|
||||||
res.send 404, {message: req.i18n.translate("password_reset_token_expired")}
|
res.sendStatus 404
|
||||||
|
|
|
@ -23,11 +23,11 @@ module.exports =
|
||||||
return callback(error) if error?
|
return callback(error) if error?
|
||||||
callback null, true
|
callback null, true
|
||||||
|
|
||||||
setNewUserPassword: (token, password, callback = (error, found) ->)->
|
setNewUserPassword: (token, password, callback = (error, found, user_id) ->)->
|
||||||
OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
|
OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, user_id)->
|
||||||
if err then return callback(err)
|
if err then return callback(err)
|
||||||
if !user_id?
|
if !user_id?
|
||||||
return callback null, false
|
return callback null, false, null
|
||||||
AuthenticationManager.setUserPassword user_id, password, (err) ->
|
AuthenticationManager.setUserPassword user_id, password, (err) ->
|
||||||
if err then return callback(err)
|
if err then return callback(err)
|
||||||
callback null, true
|
callback null, true, user_id
|
|
@ -100,7 +100,7 @@ module.exports =
|
||||||
OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
|
OneTimeTokenHandler.getNewToken user._id, { expiresIn: ONE_WEEK }, (err, token)->
|
||||||
return next(err) if err?
|
return next(err) if err?
|
||||||
|
|
||||||
setNewPasswordUrl = "#{settings.siteUrl}/user/password/set?passwordResetToken=#{token}&email=#{encodeURIComponent(email)}"
|
setNewPasswordUrl = "#{settings.siteUrl}/user/activate?token=#{token}&user_id=#{user._id}"
|
||||||
|
|
||||||
EmailHandler.sendEmail "registered", {
|
EmailHandler.sendEmail "registered", {
|
||||||
to: user.email
|
to: user.email
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
UserLocator = require("./UserLocator")
|
UserLocator = require("./UserLocator")
|
||||||
|
UserGetter = require("./UserGetter")
|
||||||
|
ErrorController = require("../Errors/ErrorController")
|
||||||
logger = require("logger-sharelatex")
|
logger = require("logger-sharelatex")
|
||||||
Settings = require("settings-sharelatex")
|
Settings = require("settings-sharelatex")
|
||||||
fs = require('fs')
|
fs = require('fs')
|
||||||
|
@ -21,10 +23,32 @@ module.exports =
|
||||||
newTemplateData: newTemplateData
|
newTemplateData: newTemplateData
|
||||||
new_email:req.query.new_email || ""
|
new_email:req.query.new_email || ""
|
||||||
|
|
||||||
|
activateAccountPage: (req, res) ->
|
||||||
|
# An 'activation' is actually just a password reset on an account that
|
||||||
|
# was set with a random password originally.
|
||||||
|
if !req.query?.user_id? or !req.query?.token?
|
||||||
|
return ErrorController.notFound(req, res)
|
||||||
|
|
||||||
|
UserGetter.getUser req.query.user_id, {email: 1, loginCount: 1}, (error, user) ->
|
||||||
|
return next(error) if error?
|
||||||
|
if !user
|
||||||
|
return ErrorController.notFound(req, res)
|
||||||
|
if user.loginCount > 0
|
||||||
|
# Already seen this user, so account must be activate
|
||||||
|
# This lets users keep clicking the 'activate' link in their email
|
||||||
|
# as a way to log in which, if I know our users, they will.
|
||||||
|
res.redirect "/login?email=#{encodeURIComponent(user.email)}"
|
||||||
|
else
|
||||||
|
res.render 'user/activate',
|
||||||
|
title: 'activate_account'
|
||||||
|
email: user.email,
|
||||||
|
token: req.query.token
|
||||||
|
|
||||||
loginPage : (req, res)->
|
loginPage : (req, res)->
|
||||||
res.render 'user/login',
|
res.render 'user/login',
|
||||||
title: 'login',
|
title: 'login',
|
||||||
redir: req.query.redir
|
redir: req.query.redir,
|
||||||
|
email: req.query.email
|
||||||
|
|
||||||
settingsPage : (req, res, next)->
|
settingsPage : (req, res, next)->
|
||||||
logger.log user: req.session.user, "loading settings page"
|
logger.log user: req.session.user, "loading settings page"
|
||||||
|
|
|
@ -77,6 +77,8 @@ module.exports = class Router
|
||||||
webRouter.get '/blog', BlogController.getIndexPage
|
webRouter.get '/blog', BlogController.getIndexPage
|
||||||
webRouter.get '/blog/*', BlogController.getPage
|
webRouter.get '/blog/*', BlogController.getPage
|
||||||
|
|
||||||
|
webRouter.get '/user/activate', UserPagesController.activateAccountPage
|
||||||
|
|
||||||
webRouter.get '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
|
webRouter.get '/user/settings', AuthenticationController.requireLogin(), UserPagesController.settingsPage
|
||||||
webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
|
webRouter.post '/user/settings', AuthenticationController.requireLogin(), UserController.updateUserSettings
|
||||||
webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
|
webRouter.post '/user/password/update', AuthenticationController.requireLogin(), UserController.changePassword
|
||||||
|
|
64
services/web/app/views/user/activate.jade
Normal file
64
services/web/app/views/user/activate.jade
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
extends ../layout
|
||||||
|
|
||||||
|
block content
|
||||||
|
.content.content-alt
|
||||||
|
.container
|
||||||
|
.row
|
||||||
|
.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
|
||||||
|
.alert.alert-success You're one step away from activating your DataJoy account!
|
||||||
|
.row
|
||||||
|
.col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4
|
||||||
|
.card
|
||||||
|
.page-header
|
||||||
|
h1 Please set a password
|
||||||
|
form(
|
||||||
|
async-form="activate",
|
||||||
|
name="activationForm",
|
||||||
|
action="/user/password/set",
|
||||||
|
method="POST",
|
||||||
|
ng-cloak
|
||||||
|
)
|
||||||
|
input(name='_csrf', type='hidden', value=csrfToken)
|
||||||
|
input(
|
||||||
|
type="hidden",
|
||||||
|
name="passwordResetToken",
|
||||||
|
value=token
|
||||||
|
)
|
||||||
|
input(name='login_after', type='hidden', value="true")
|
||||||
|
.alert.alert-danger(ng-show="activationForm.response.error")
|
||||||
|
| #{translate("activation_token_expired")}
|
||||||
|
|
||||||
|
.form-group
|
||||||
|
label(for='email') #{translate("email")}
|
||||||
|
input.form-control(
|
||||||
|
type='email',
|
||||||
|
name='email',
|
||||||
|
placeholder="email@example.com"
|
||||||
|
required,
|
||||||
|
ng-model="email",
|
||||||
|
ng-init="email = #{JSON.stringify(email)}",
|
||||||
|
ng-model-options="{ updateOn: 'blur' }",
|
||||||
|
disabled
|
||||||
|
)
|
||||||
|
.form-group
|
||||||
|
label(for='password') #{translate("password")}
|
||||||
|
input.form-control#passwordField(
|
||||||
|
type='password',
|
||||||
|
name='password',
|
||||||
|
placeholder="********",
|
||||||
|
required,
|
||||||
|
ng-model="password",
|
||||||
|
complex-password,
|
||||||
|
focus="true"
|
||||||
|
)
|
||||||
|
span.small.text-primary(ng-show="activationForm.password.$error.complexPassword", ng-bind-html="complexPasswordErrorMessage")
|
||||||
|
.actions
|
||||||
|
button.btn-primary.btn(
|
||||||
|
type='submit'
|
||||||
|
ng-disabled="activationForm.inflight || activationForm.password.$error.required|| activationForm.password.$error.complexPassword"
|
||||||
|
)
|
||||||
|
span(ng-show="!activationForm.inflight") #{translate("activate")}
|
||||||
|
span(ng-show="activationForm.inflight") #{translate("activating")}...
|
||||||
|
|
||||||
|
script(type='text/javascript').
|
||||||
|
window.passwordStrengthOptions = !{JSON.stringify(settings.passwordStrengthOptions || {})}
|
|
@ -20,6 +20,7 @@ block content
|
||||||
placeholder='email@example.com',
|
placeholder='email@example.com',
|
||||||
ng-model="email",
|
ng-model="email",
|
||||||
ng-model-options="{ updateOn: 'blur' }",
|
ng-model-options="{ updateOn: 'blur' }",
|
||||||
|
ng-init="email = #{JSON.stringify(email)}",
|
||||||
focus="true"
|
focus="true"
|
||||||
)
|
)
|
||||||
span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty")
|
span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty")
|
||||||
|
|
|
@ -16,10 +16,11 @@ block content
|
||||||
ng-cloak
|
ng-cloak
|
||||||
)
|
)
|
||||||
input(type="hidden", name="_csrf", value=csrfToken)
|
input(type="hidden", name="_csrf", value=csrfToken)
|
||||||
form-messages(for="passwordResetForm")
|
.alert.alert-success(ng-show="passwordResetForm.response.success")
|
||||||
.alert.alert-success(ng-show="passwordResetForm.response.success")
|
| #{translate("password_has_been_reset")}.
|
||||||
| #{translate("password_has_been_reset")}.
|
a(href='/login') #{translate("login_here")}
|
||||||
a(href='/login') #{translate("login_here")}
|
.alert.alert-danger(ng-show="passwordResetForm.response.error")
|
||||||
|
| #{translate("password_reset_token_expired")}
|
||||||
|
|
||||||
.form-group
|
.form-group
|
||||||
input.form-control#passwordField(
|
input.form-control#passwordField(
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
should = require('chai').should()
|
should = require('chai').should()
|
||||||
|
expect = require("chai").expect
|
||||||
SandboxedModule = require('sandboxed-module')
|
SandboxedModule = require('sandboxed-module')
|
||||||
assert = require('assert')
|
assert = require('assert')
|
||||||
path = require('path')
|
path = require('path')
|
||||||
|
@ -21,6 +22,8 @@ describe "PasswordResetController", ->
|
||||||
"./PasswordResetHandler":@PasswordResetHandler
|
"./PasswordResetHandler":@PasswordResetHandler
|
||||||
"logger-sharelatex": log:->
|
"logger-sharelatex": log:->
|
||||||
"../../infrastructure/RateLimiter":@RateLimiter
|
"../../infrastructure/RateLimiter":@RateLimiter
|
||||||
|
"../Authentication/AuthenticationController": @AuthenticationController = {}
|
||||||
|
"../User/UserGetter": @UserGetter = {}
|
||||||
|
|
||||||
@email = "bob@bob.com "
|
@email = "bob@bob.com "
|
||||||
@token = "my security token that was emailed to me"
|
@token = "my security token that was emailed to me"
|
||||||
|
@ -101,7 +104,7 @@ describe "PasswordResetController", ->
|
||||||
|
|
||||||
it "should send 404 if the token didn't work", (done)->
|
it "should send 404 if the token didn't work", (done)->
|
||||||
@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, false)
|
@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, false)
|
||||||
@res.send = (code)=>
|
@res.sendStatus = (code)=>
|
||||||
code.should.equal 404
|
code.should.equal 404
|
||||||
done()
|
done()
|
||||||
@PasswordResetController.setNewUserPassword @req, @res
|
@PasswordResetController.setNewUserPassword @req, @res
|
||||||
|
@ -132,6 +135,19 @@ describe "PasswordResetController", ->
|
||||||
done()
|
done()
|
||||||
@PasswordResetController.setNewUserPassword @req, @res
|
@PasswordResetController.setNewUserPassword @req, @res
|
||||||
|
|
||||||
|
it "should login user if login_after is set", (done) ->
|
||||||
|
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, { email: "joe@example.com" })
|
||||||
|
@PasswordResetHandler.setNewUserPassword.callsArgWith(2, null, true, @user_id = "user-id-123")
|
||||||
|
@req.body.login_after = "true"
|
||||||
|
@AuthenticationController.doLogin = (options, req, res, next)=>
|
||||||
|
@UserGetter.getUser.calledWith(@user_id).should.equal true
|
||||||
|
expect(options).to.deep.equal {
|
||||||
|
email: "joe@example.com",
|
||||||
|
password: @password
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
@PasswordResetController.setNewUserPassword @req, @res
|
||||||
|
|
||||||
describe "renderSetPasswordForm", ->
|
describe "renderSetPasswordForm", ->
|
||||||
|
|
||||||
describe "with token in query-string", ->
|
describe "with token in query-string", ->
|
||||||
|
|
|
@ -80,8 +80,9 @@ describe "PasswordResetHandler", ->
|
||||||
it "should set the user password", (done)->
|
it "should set the user password", (done)->
|
||||||
@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
|
@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @user_id)
|
||||||
@AuthenticationManager.setUserPassword.callsArgWith(2)
|
@AuthenticationManager.setUserPassword.callsArgWith(2)
|
||||||
@PasswordResetHandler.setNewUserPassword @token, @password, (err, found) =>
|
@PasswordResetHandler.setNewUserPassword @token, @password, (err, found, user_id) =>
|
||||||
found.should.equal true
|
found.should.equal true
|
||||||
|
user_id.should.equal @user_id
|
||||||
@AuthenticationManager.setUserPassword.calledWith(@user_id, @password).should.equal true
|
@AuthenticationManager.setUserPassword.calledWith(@user_id, @password).should.equal true
|
||||||
done()
|
done()
|
||||||
|
|
||||||
|
|
|
@ -200,7 +200,7 @@ describe "UserController", ->
|
||||||
@EmailHandler.sendEmail
|
@EmailHandler.sendEmail
|
||||||
.calledWith("registered", {
|
.calledWith("registered", {
|
||||||
to: @user.email
|
to: @user.email
|
||||||
setNewPasswordUrl: "#{@settings.siteUrl}/user/password/set?passwordResetToken=#{@token}&email=#{encodeURIComponent(@user.email)}"
|
setNewPasswordUrl: "#{@settings.siteUrl}/user/activate?token=#{@token}&user_id=#{@user_id}"
|
||||||
})
|
})
|
||||||
.should.equal true
|
.should.equal true
|
||||||
|
|
||||||
|
@ -208,7 +208,7 @@ describe "UserController", ->
|
||||||
@res.json
|
@res.json
|
||||||
.calledWith({
|
.calledWith({
|
||||||
email: @user.email
|
email: @user.email
|
||||||
setNewPasswordUrl: "#{@settings.siteUrl}/user/password/set?passwordResetToken=#{@token}&email=#{encodeURIComponent(@user.email)}"
|
setNewPasswordUrl: "#{@settings.siteUrl}/user/activate?token=#{@token}&user_id=#{@user_id}"
|
||||||
})
|
})
|
||||||
.should.equal true
|
.should.equal true
|
||||||
|
|
||||||
|
|
|
@ -12,18 +12,25 @@ describe "UserPagesController", ->
|
||||||
|
|
||||||
@settings = {}
|
@settings = {}
|
||||||
@user =
|
@user =
|
||||||
_id:"kwjewkl"
|
_id: @user_id = "kwjewkl"
|
||||||
features:{}
|
features:{}
|
||||||
|
email: "joe@example.com"
|
||||||
|
|
||||||
@UserLocator =
|
@UserLocator =
|
||||||
findById: sinon.stub().callsArgWith(1, null, @user)
|
findById: sinon.stub().callsArgWith(1, null, @user)
|
||||||
|
@UserGetter =
|
||||||
|
getUser: sinon.stub().callsArgWith(2, null, @user)
|
||||||
@dropboxStatus = {}
|
@dropboxStatus = {}
|
||||||
@DropboxHandler =
|
@DropboxHandler =
|
||||||
getUserRegistrationStatus : sinon.stub().callsArgWith(1, null, @dropboxStatus)
|
getUserRegistrationStatus : sinon.stub().callsArgWith(1, null, @dropboxStatus)
|
||||||
|
@ErrorController =
|
||||||
|
notFound: sinon.stub()
|
||||||
@UserPagesController = SandboxedModule.require modulePath, requires:
|
@UserPagesController = SandboxedModule.require modulePath, requires:
|
||||||
"settings-sharelatex":@settings
|
"settings-sharelatex":@settings
|
||||||
"logger-sharelatex": log:->
|
"logger-sharelatex": log:->
|
||||||
"./UserLocator": @UserLocator
|
"./UserLocator": @UserLocator
|
||||||
|
"./UserGetter": @UserGetter
|
||||||
|
"../Errors/ErrorController": @ErrorController
|
||||||
'../Dropbox/DropboxHandler': @DropboxHandler
|
'../Dropbox/DropboxHandler': @DropboxHandler
|
||||||
@req =
|
@req =
|
||||||
query:{}
|
query:{}
|
||||||
|
@ -104,3 +111,43 @@ describe "UserPagesController", ->
|
||||||
opts.user.should.equal @user
|
opts.user.should.equal @user
|
||||||
done()
|
done()
|
||||||
@UserPagesController.settingsPage @req, @res
|
@UserPagesController.settingsPage @req, @res
|
||||||
|
|
||||||
|
describe "activateAccountPage", ->
|
||||||
|
beforeEach ->
|
||||||
|
@req.query.user_id = @user_id
|
||||||
|
@req.query.token = @token = "mock-token-123"
|
||||||
|
|
||||||
|
it "should 404 without a user_id", (done) ->
|
||||||
|
delete @req.query.user_id
|
||||||
|
@ErrorController.notFound = () ->
|
||||||
|
done()
|
||||||
|
@UserPagesController.activateAccountPage @req, @res
|
||||||
|
|
||||||
|
it "should 404 without a token", (done) ->
|
||||||
|
delete @req.query.token
|
||||||
|
@ErrorController.notFound = () ->
|
||||||
|
done()
|
||||||
|
@UserPagesController.activateAccountPage @req, @res
|
||||||
|
|
||||||
|
it "should 404 without a valid user_id", (done) ->
|
||||||
|
@UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
|
||||||
|
@ErrorController.notFound = () ->
|
||||||
|
done()
|
||||||
|
@UserPagesController.activateAccountPage @req, @res
|
||||||
|
|
||||||
|
it "should redirect activated users to login", (done) ->
|
||||||
|
@user.loginCount = 1
|
||||||
|
@res.redirect = (url) =>
|
||||||
|
@UserGetter.getUser.calledWith(@user_id).should.equal true
|
||||||
|
url.should.equal "/login?email=#{encodeURIComponent(@user.email)}"
|
||||||
|
done()
|
||||||
|
@UserPagesController.activateAccountPage @req, @res
|
||||||
|
|
||||||
|
it "render the activation page if the user has not logged in before", (done) ->
|
||||||
|
@user.loginCount = 0
|
||||||
|
@res.render = (page, opts) =>
|
||||||
|
page.should.equal "user/activate"
|
||||||
|
opts.email.should.equal @user.email
|
||||||
|
opts.token.should.equal @token
|
||||||
|
done()
|
||||||
|
@UserPagesController.activateAccountPage @req, @res
|
Loading…
Reference in a new issue