Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to email verification codes rather than links on signup #4195

Merged
merged 9 commits into from
Jul 18, 2024
52 changes: 50 additions & 2 deletions forge/db/controllers/AccessToken.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { Op } = require('sequelize')

const { generateToken, sha256, randomPhrase } = require('../utils')
const { generateToken, generateNumericToken, sha256, randomPhrase } = require('../utils')

const DEFAULT_TOKEN_SESSION_EXPIRY = 1000 * 60 * 30 // 30 mins session - with refresh token support

Expand Down Expand Up @@ -58,7 +58,37 @@ module.exports = {
}
})
},
/**
* Create an AccessToken for a user's email verification
*/
createTokenForEmailVerification: async function (app, user) {
// Ensure any existing tokens are removed first
await app.db.controllers.AccessToken.deleteAllUserEmailVerificationTokens(user)

const token = generateNumericToken()
const expiresAt = new Date(Date.now() + (1000 * 60 * 30)) // 30 minutes
await app.db.models.AccessToken.create({
token,
expiresAt,
scope: 'email:verify',
ownerId: '' + user.id,
ownerType: 'user'
})
return { token }
},

/**
* Deletes any pending email-verification tokens for a user.
*/
deleteAllUserEmailVerificationTokens: async function (app, user) {
await app.db.models.AccessToken.destroy({
where: {
ownerType: 'user',
scope: 'email:verify',
ownerId: '' + user.id
}
})
},
/**
* Create an AccessToken for the given device.
* The token is hashed in the database. The only time the
Expand Down Expand Up @@ -291,7 +321,7 @@ module.exports = {
where: {
token: sha256(token),
scope: {
[Op.ne]: 'password:reset'
[Op.notIn]: ['password:reset', 'email:verify']
}
}
})
Expand Down Expand Up @@ -320,6 +350,24 @@ module.exports = {
return accessToken
},

getOrExpireEmailVerificationToken: async function (app, user, token) {
let accessToken = await app.db.models.AccessToken.findOne({
where: {
token: sha256(token),
ownerId: '' + user.id,
ownerType: 'user',
scope: 'email:verify'
}
})
if (accessToken) {
if (accessToken.expiresAt && accessToken.expiresAt.getTime() < Date.now()) {
await accessToken.destroy()
accessToken = null
}
}
return accessToken
},

destroyToken: async function (app, token) {
const accessToken = await app.db.models.AccessToken.findOne({
where: {
Expand Down
42 changes: 8 additions & 34 deletions forge/db/controllers/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,44 +96,18 @@ module.exports = {
},

generateEmailVerificationToken: async function (app, user) {
const TOKEN_EXPIRY = 1000 * 60 * 60 * 24 * 2 // 48 Hours
const expiresAt = Math.floor((Date.now() + TOKEN_EXPIRY) / 1000) // 48 hours
const signingHash = sha256(user.password)
return jwt.sign({ sub: user.email, exp: expiresAt }, signingHash)
return app.db.controllers.AccessToken.createTokenForEmailVerification(user)
},

verifyEmailToken: async function (app, user, token) {
// Get the email from the token (.sub)
const peekToken = jwt.decode(token)
if (peekToken && peekToken.sub) {
// Get the corresponding user
const requestingUser = await app.db.models.User.byEmail(peekToken.sub)
if (user && user.id !== requestingUser.id) {
throw new Error('Invalid link')
}
if (requestingUser.email_verified) {
throw new Error('Link expired')
}
if (requestingUser) {
// Verify the token
const signingHash = app.db.utils.sha256(requestingUser.password)
try {
const decodedToken = jwt.verify(token, signingHash)
if (decodedToken) {
requestingUser.email_verified = true
await requestingUser.save()
return requestingUser
}
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('Link expired')
} else {
throw new Error('Invalid link')
}
}
}
const accessToken = await app.db.controllers.AccessToken.getOrExpireEmailVerificationToken(user, token)
await app.db.controllers.AccessToken.deleteAllUserEmailVerificationTokens(user)
if (!accessToken) {
throw new Error('Invalid token')
}
throw new Error('Invalid link')
user.email_verified = true
await user.save()
return user
},
verifyMFAToken: async function (app, user, token) {
if (!app.config.features.enabled('mfa')) {
Expand Down
1 change: 1 addition & 0 deletions forge/db/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ function randomPhrase (wordCount = 3, minLength = 2, maxLength = 15, separator =
module.exports = {
init: _app => { app = _app },
generateToken: (length, prefix) => (prefix ? prefix + '_' : '') + base64URLEncode(crypto.randomBytes(length || 32)),
generateNumericToken: () => crypto.randomInt(0, 1000000).toString().padStart(6, '0'),
hash: value => bcrypt.hashSync(value, 10),
compareHash: (plain, hashed) => bcrypt.compareSync(plain, hashed),
md5,
Expand Down
11 changes: 7 additions & 4 deletions forge/postoffice/templates/VerifyEmail.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ module.exports = {
text:
`Hello, {{{safeName.text}}},

Use the link below to verify your email address.
Please use this code to verify your email address:

{{{ confirmEmailLink }}}
{{{ token.token }}}

Do not share this code with anyone else.
`,
html:
`<p>Hello, <b>{{{safeName.html}}}</b>,</p>
<p>Use the link below to verify your email address.</p>
<p><a href="{{{ confirmEmailLink }}}">Confirm Email address</a></p>
<p>Please use this code to verify your email address:</p>
<p><b>{{{ token.token }}}</b></p>
<p>Do not share this code with anyone else.
`
}
2 changes: 1 addition & 1 deletion forge/routes/api/shared/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ module.exports = {
user,
'VerifyEmail',
{
confirmEmailLink: `${app.config.base_url}/account/verify/${verificationToken}`
token: verificationToken
}
)
} catch (error) {
Expand Down
17 changes: 10 additions & 7 deletions forge/routes/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ async function init (app, opts) {
newUser,
'VerifyEmail',
{
confirmEmailLink: `${app.config.base_url}/account/verify/${verificationToken}`
token: verificationToken
}
)
}
Expand All @@ -462,6 +462,12 @@ async function init (app, opts) {
// invite.inviteeId = verifiedUser.id
// await invite.save()
}
} else {
// Log them in
const sessionInfo = await app.createSessionCookie(newUser.username)
if (sessionInfo) {
reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions)
}
}

reply.send(await app.db.views.User.userProfile(newUser))
Expand All @@ -488,10 +494,7 @@ async function init (app, opts) {
/**
* Perform email verification
*/
app.post('/account/verify/:token', {
config: {
rateLimit: false // never rate limit this route
},
app.post('/account/verify/token', {
schema: {
tags: ['Authentication', 'X-HIDDEN']
}
Expand All @@ -504,7 +507,7 @@ async function init (app, opts) {
}
let verifiedUser
try {
verifiedUser = await app.db.controllers.User.verifyEmailToken(sessionUser, request.params.token)
verifiedUser = await app.db.controllers.User.verifyEmailToken(sessionUser, request.body.token)
} catch (err) {
const resp = { code: 'invalid_request', error: err.toString() }
await app.auditLog.User.account.verify.verifyToken(request.session?.User, resp)
Expand Down Expand Up @@ -659,7 +662,7 @@ async function init (app, opts) {
request.session.User,
'VerifyEmail',
{
confirmEmailLink: `${app.config.base_url}/account/verify/${verificationToken}`
token: verificationToken
}
)
await app.auditLog.User.account.verify.requestToken(request.session.User, null)
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ const triggerVerification = async () => {
* @returns {Promise}
*/
const verifyEmailToken = async (token) => {
return client.post(`/account/verify/${token}`).then(res => {
return client.post('/account/verify/token', {
token
}).then(res => {
return res.data
})
}
Expand Down
52 changes: 42 additions & 10 deletions frontend/src/pages/UnverifiedEmail.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
<template>
<ff-layout-box>
<form class="px-4 sm:px-6 lg:px-8 mt-8 space-y-6 max-w-md" @submit.prevent>
<form class="px-4 sm:px-6 lg:px-8 mt-8 mx-auto space-y-6 max-w-md" @submit.prevent>
<p>
Before you can access the platform, we need to verify your email
address.
</p>
<p>
We sent you an email with a link to click when you signed up.
We sent you an email with a code in it. Enter the code below to continue.
</p>
<div>
<ff-text-input v-model="token" data-form="verify-token" maxlength="6" label="token" @enter="submitVerificationToken" />
<span class="ff-error-inline" data-el="token-error">{{ error }}</span>
</div>

<ff-button :disabled="token.length !== 6" data-action="submit-verify-token" @click="submitVerificationToken">Continue</ff-button>
<p>
<ff-button kind="tertiary" :disabled="resendTimeoutCount > 0" @click="resend">
<span>Resend email <span v-if="resendTimeoutCount > 0">({{ resendTimeoutCount }})</span></span>
</ff-button>
<ff-button kind="tertiary" @click="logout">Log out</ff-button>
</p>
<ff-button :disabled="sent" @click="resend">
<span v-if="!sent">Resend email</span>
<span v-else>Sent</span>
</ff-button>
<ff-button kind="tertiary" @click="logout">Log out</ff-button>
</form>
</ff-layout-box>
</template>
Expand All @@ -31,16 +38,41 @@ export default {
},
data () {
return {
sent: false
token: '',
error: '',
resendTimeoutCount: 0,
resendTimeout: null
}
},
computed: mapState('account', ['user']),
methods: {
async submitVerificationToken () {
try {
await userApi.verifyEmailToken(this.token)
clearTimeout(this.resendTimeout)
window.location = '/'
} catch (err) {
// Verification failed.
this.token = ''
this.error = 'Verification failed. Click resend to receive a new code to try again'
clearTimeout(this.resendTimeout)
this.resentTimeout = 0
}
},
async resend () {
if (!this.sent) {
this.sent = true
this.resendTimeoutCount = 30
try {
await userApi.triggerVerification()
} catch (err) {

}
const tick = () => {
this.resendTimeoutCount--
if (this.resendTimeoutCount > 0) {
this.resendTimeout = setTimeout(tick, 1000)
}
}
tick()
},
logout () {
store.dispatch('account/logout')
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/pages/account/Create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<template v-if="splash" #splash-content>
<div data-el="splash" v-html="splash" />
</template>
<form v-if="!emailSent && !ssoCreated" id="ff-sign-up" class="max-w-md m-auto" @submit.prevent="registerUser()">
<form v-if="!ssoCreated" id="ff-sign-up" class="max-w-md m-auto" @submit.prevent="registerUser()">
<p
v-if="settings['branding:account:signUpTopBanner']"
data-el="banner-text"
Expand Down Expand Up @@ -53,11 +53,7 @@
</p>
</div>
</form>
<div v-else-if="emailSent">
<h5>Confirm your e-mail address.</h5>
<p>Please click the link in the email we sent to {{ input.email }}</p>
</div>
<div v-else>
<div v-else-if="ssoCreated">
<p>You can now login using your SSO Provider.</p>
<ff-button :to="{ name: 'Home' }" data-action="login">Login</ff-button>
</div>
Expand Down Expand Up @@ -93,7 +89,6 @@ export default {
name: false
},
teams: [],
emailSent: false,
ssoCreated: false,
input: {
name: '',
Expand Down Expand Up @@ -232,16 +227,18 @@ export default {
const opts = { ...this.input, name: name || this.input.username, email }
this.busy = true // show spinner
this.errors.general = '' // clear any previous errors
userApi.registerUser(opts).then(result => {
userApi.registerUser(opts).then(async result => {
if (result.sso_enabled) {
this.ssoCreated = true
} else {
this.emailSent = true
}
this.busy = false
if (window.gtag && this.settings.adwords?.events?.conversion) {
window.gtag('event', 'conversion', this.settings.adwords.events.conversion)
}
if (!result.sso_enabled) {
this.$store.dispatch('account/setUser', result)
this.$router.push('/')
}
}).catch(err => {
console.error(err)
this.busy = false
Expand Down
Loading
Loading