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
12 changes: 5 additions & 7 deletions docs/contribute/workflows/signup.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ meta:

```mermaid
sequenceDiagram
%% https://mermaid.live/edit#pako:eNp9VWtv4joQ_SsWV6t-2GVb0gIp0l0JSHiHQB4UUKXIxCaYJHZwHAit9r-vA7SX7fYun0LmzJkzMyf2a8lnCJcapRTvMkx9rBEYcBg_UyB_X76AjRBJ2ri9jTGPIUHfI7LHtxgR8U8CQ9bA4914pisTZducena6nb9sUzerWVk0uCd2Yj7lPdutOUN48Fo2bznrSqtDJh5OrTFbdCJhLuaGWs-rq0pVXRqD_rIVvZS1LOjwjbsfLdW5Ege9g4dro5Y3fdmr9fq2Yu-ICQ-PkSbyVg1pMa7VofmgEW20cZ7qFU88VnVPSxj3Vg97nqs9UZvl65dkN8ujsJtNKqyjO97U3DYhhdu7Sc9SuDpdqXeEW2Y76Oadje-uO5u9T4NcyfJg6s4PG61-iIeCauXJHj1M4T4yRnZ-3Obj1s4aTtZ3eDwW0Na9psMR2x1WCokdGE_nFcuvLnr3IoeoHC2qm9DdtWdBjUBjwDqwZjHzToXiLlW8eGYmztF_snK16pSN4Q51XdSbdyzqvEBDtajpl7NmHidZ71EzVs5Yh9FAqXZWI_1lrWhKGk6sabfc7mHoO7P7yaJsHo_6Uhs1E7dVtR1Jozs7FcG9v-4Gyx3kyOWxqNbz_jyBTwMeOEfDZY7vz6bpk-VNKq25Nq61k1xbE9WgStOzDTevTnAzavvhrKUYVtQ3BcMdpT1UEli1AzTg94OI5e2obKsdT9tGnfKjAw3EV100DCc-1zU1zLuKy9aRgVd1w4rva7aKzVHutaP1dLla9NN55ZAvZW-mj5faMFdDulgG3c0LPFq7iO2takwqcaTYkxU7uiiMa-7iYM23huNU1cMRJ44_3jzxx3506M96odVtpsc6VNF623-gFeexszuMhswL1AWHro28YLjw-_b87HmYCUazeIX5-X8CuSA-SSAVwNUBTIGbYq7LjyH6BGC_AT6J9f98ZzkF3sqoIDH-M6y1zu9cu_zjh9tvAJ0KzFOAsJDlU8AoSElAy1ki0wL8AdyOiB-mwD4jLsG-DH61nAaYmLYDbqHvM1n9luOApOJNtuVIlNaSFBxDga8aOkWK9C6mmBex0yTADHOyJj4URGpyWIjpezdv7QHBPo7undHVG8DGFAF8YvMZlQ1SQgOwvyaOCA3_03wKHW9fRVHu52V7kuO6Xv-3QrJSuZjMK0gFFFnaADcshMcbcMmWCp7p1aAKrL1hB3DT3mA_BEeW8bPEm-uB6wWxazcus-DYx_KURFfr-FowmQmm6d_ln6tKJo2kSQSP6amM9EhYzCKT0yu6ei6dc6_0AIgQx2n6XPrMBDJjds74AAarTEi7_90bnwl998FpMCm4BAFJwZpJkRsMIhYEGAFCT7o_GKswwveTGO-8YYz-FTy7TDRiLDnxyJNsA-TYUNE-oXsi8AfnFGRNhN5nI_Db5fUbRMMRlmb9Xwo3QYWZYSZvtkL5lR-uvSM3Y8nLT25YgJvbm2da-la63IzyDn0twM8l2bz8mEsN-YggD4uV_JS47FRClwUYLzXWMErxt1Jx2thH6pcaRfdvoMslfEH9_AUC2oYO
autonumber
participant UE as UserEmail
participant US as User
Expand All @@ -21,17 +20,16 @@ sequenceDiagram
RT->>DB: Create User
RT->>RT: Generate Email Verification Token
par Runtime to UserEmail
RT->>UE: Send email containing verification link /account/verify/{token}
RT->>UE: Send email containing verification code
and Runtime to UI
RT-->>-UI: { status: 'okay' }
RT-->>-UI: { status: 'okay' } and session created
end

UI->>UI: Show 'Check your email' page
UE-->>US: Email received
US->>+UI: Opens /account/verify/{token}
UI->>US: Displays page asking user to "Please verify your email"
US->>UI: Click "Verify my email" button
UI->>+RT: POST /account/verify/{token}
US->>+UI: Enters verification code
US->>UI: Click "Continue" button
UI->>+RT: POST /account/verify/token { token }
RT->>RT: Checks {token} is for the logged in user
RT->>DB: User.email_verified=true
loop for each pending invite
Expand Down
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
14 changes: 10 additions & 4 deletions forge/postoffice/templates/VerifyEmail.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ 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.

This token will expire in 30 minutes.
`,
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.</p>
<p>This token will expire in 30 minutes.</p>
`
}
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
23 changes: 18 additions & 5 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,9 +494,16 @@ async function init (app, opts) {
/**
* Perform email verification
*/
app.post('/account/verify/:token', {
app.post('/account/verify/token', {
config: {
rateLimit: false // never rate limit this route
rateLimit: app.config.rate_limits
? {
max: 2,
timeWindow: 60000,
keyGenerator: app.config.rate_limits.keyGenerator,
hard: true
}
: false
},
schema: {
tags: ['Authentication', 'X-HIDDEN']
Expand All @@ -504,7 +517,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 +672,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.resendTimeout = 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
Loading
Loading