Registration page: the initial commit

This commit is contained in:
yu-i-i
2026-07-17 18:07:19 +02:00
parent fdeff3c174
commit aaea099e48
13 changed files with 348 additions and 6 deletions
@@ -0,0 +1,101 @@
import Path from 'path'
import logger from '@overleaf/logger'
import registerNewUserAndSendActivationEmail from './UserRegistrationHandler.mjs'
import EmailHelper from '../../../../app/src/Features/Helpers/EmailHelper.mjs'
import Settings from '@overleaf/settings'
async function registrationPage(req, res, next) {
// Check if the user is already logged in
if (req.user != null) {
return res.redirect(`/`)
}
const sharedProjectData = req.session.sharedProjectData || {}
const newTemplateData = {}
if (req.session.templateData != null) {
newTemplateData.templateName = req.session.templateData.templateName
}
const allowedDomains = Settings.allowedRegistrationEmailDomains || []
const displayDomains = new Map()
for (const domain of allowedDomains) {
const subdomainsOnly = domain.startsWith('*.')
const base = subdomainsOnly ? domain.substring(2) : domain
const existing = displayDomains.get(base)
displayDomains.set(base, {
domain: base,
exact: existing?.exact || !subdomainsOnly,
subdomains: existing?.subdomains || subdomainsOnly,
})
}
const __dirname = Path.dirname(new URL(import.meta.url).pathname)
res.render(Path.resolve(__dirname, '../views/register'), {
title: 'register',
sharedProjectData,
newTemplateData,
displayDomains: [...displayDomains.values()],
csrfToken: req.csrfToken(),
})
}
async function registerNewUser(req, res, next) {
const { email, first_name, last_name } = req.body
if (
typeof first_name !== 'string' || first_name.length > 100 ||
typeof last_name !== 'string' || last_name.length > 100
) {
return res.status(400).json({ message: 'Too long name.' })
}
const parsedEmail = EmailHelper.parseEmail(email)
if (!parsedEmail) {
return res.status(400).json({ message: 'Invalid email address.' })
}
// If registration is restricted to a specific email domains,
// check that the email domain is allowed
const domain = parsedEmail.split('@').pop()
const allowedDomains = Settings.allowedRegistrationEmailDomains
if (
allowedDomains &&
!allowedDomains.some(pattern => {
if (pattern.startsWith('*.')) {
const base = pattern.slice(2)
return domain.endsWith(`.${base}`)
}
return domain === pattern
})
) {
return res.status(403).json({
message: 'Registration is not available for this email domain.',
})
}
const t = req.i18n.translate
try {
const userDetails = { email, first_name, last_name }
const success = await registerNewUserAndSendActivationEmail(userDetails)
if (success) {
return res.status(200).json({ message: t('register_success') })
} else {
return res.status(409).json({ message: { key: 'account_with_this_email_exists' } })
}
} catch (error) {
if (error.message === 'error sending message') {
return res.status(422).json({ message: t('failed_to_send_registration_email') })
}
return next(error)
}
}
export default {
registrationPage,
registerNewUser
}
@@ -0,0 +1,33 @@
import logger from "@overleaf/logger"
import Settings from "@overleaf/settings"
import RegistrationPageController from './RegistrationPageController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
import RateLimiterMiddleware from "../../../../app/src/Features/Security/RateLimiterMiddleware.mjs"
import { RateLimiter } from "../../../../app/src/infrastructure/RateLimiter.mjs"
// Limit registration attempts to 3 per minute per IP
const registrationRateLimiters = {
postRegister: new RateLimiter('postRegister', {
points: 5,
duration: 60,
}),
}
export default {
apply(webRouter) {
logger.debug({}, 'Init Registration Page module')
// remove default registration router if it exists
webRouter.stack = webRouter.stack.filter(layer => {
return !(layer.route && layer.route.path === '/register' && layer.route.methods.get)
})
webRouter.get('/register', RegistrationPageController.registrationPage)
webRouter.post(
'/register',
RateLimiterMiddleware.rateLimit(registrationRateLimiters.postRegister),
RegistrationPageController.registerNewUser
)
}
}
@@ -0,0 +1,51 @@
import crypto from 'node:crypto'
import settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs'
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs'
import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.mjs'
const ONE_WEEK = 7 * 24 * 60 * 60 // seconds
export default async function registerNewUserAndSendActivationEmail(userData) {
let user
try {
const password = crypto.randomBytes(32).toString('hex')
user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password })
} catch (error) {
if (error.message === 'EmailAlreadyRegistered') {
logger.debug({ email: userData.email }, 'user already registered')
return false
}
throw error
}
try {
const token = await OneTimeTokenHandler.promises.getNewToken(
'password',
{ user_id: user._id.toString(), email: user.email },
{ expiresIn: ONE_WEEK }
)
const setNewPasswordUrl = `${settings.siteUrl}/user/activate?token=${token}&user_id=${user._id}`
await EmailHandler.promises.sendEmail('registered', {
to: user.email,
setNewPasswordUrl,
})
} catch (error) {
try {
await UserDeleter.promises.deleteUser(user._id, {
ipAddress: '127.0.0.1',
skipEmail: true,
})
await UserDeleter.promises.expireDeletedUser(user._id)
} catch (err) {
logger.error({ err, userId: user._id }, 'failed delete user')
}
throw error
}
return true
}
@@ -0,0 +1 @@
{ "extends": "../../../../tsconfig.backend.json" }
@@ -0,0 +1,98 @@
extends ../../../../app/views/layout-website-redesign
block vars
- isWebsiteRedesign = true
block content
main#main-content.content
.container
.row
.registration-message
if sharedProjectData.user_first_name !== undefined
h1 #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})}
em #{sharedProjectData.project_name}
div
| #{translate("join_sl_to_view_project")}.
div
| #{translate("already_have_sl_account")}
|
a(href='/login') #{translate("login_here")}
else if newTemplateData.templateName !== undefined
h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
div #{translate("already_have_sl_account")}
|
a(href='/login') #{translate("login_here")}
.row
.col-lg-8.offset-lg-2.col-xl-6.offset-xl-3
.page-header
h1.login-register-header-heading-focus.h3 #{translate("register")}
if displayDomains.length
.alert.alert-info
div.small.fst-italic.mb-2 #{translate("registration_is_restricted_to_the_following_email_domains")}
ul.mb-0.small
each item in displayDomains
li
span.small.fst-italic
code #{item.domain}
if item.subdomains && item.exact
| (#{translate("including_all_subdomains")})
else if item.subdomains
| (#{translate("subdomains_of_this_domain_only")})
form(
data-ol-async-form
name="registrationForm"
action="/register",
method="POST",
aria-label=translate('create_account')
)
input(name='_csrf' type='hidden' value=csrfToken)
+formMessagesNewStyle()
+customFormMessageNewStyle('account_with_this_email_exists', 'warn')
| !{translate("account_with_this_email_exists")}
a.d-block.mt-1(href='/user/password/reset') #{translate("forgot_your_password")}?
.form-group
label(for='firstNameField') #{translate("first_name")}
input.form-control(
aria-label="first_name"
id='firstNameField'
type='text',
name='first_name',
placeholder=translate("first_name"),
required,
autocomplete="given-name",
maxlength="100"
autofocus
)
.form-group
label(for='lastNameField') #{translate("last_name")}
input.form-control(
aria-label="last_name"
id='lastNameField'
type='text',
name='last_name',
placeholder=translate("last_name"),
required,
autocomplete="family-name",
maxlength="100"
)
.form-group
label(for='emailField') #{translate("email")}
input.form-control(
aria-label="email"
id='emailField'
type='email',
name='email',
placeholder=translate("email"),
required,
autocomplete="username",
maxlength="100"
)
div(style="margin-top: 3rem")
.actions
button.btn.btn-primary.w-100(
type='submit',
data-ol-disabled-inflight
)
span(data-ol-inflight="idle") #{translate("create_account")}
span(hidden data-ol-inflight="pending") #{translate("registering")}…
@@ -0,0 +1,30 @@
import Settings from '@overleaf/settings'
import { boolFromEnv } from '../authentication/utils.mjs'
let RegistrationPageModule = {}
let enableRegistrationPage = boolFromEnv(process.env.OVERLEAF_ENABLE_REGISTRATION_PAGE)
// if the env var is not set, enable registration page iff the external authentication is not enabled
if (enableRegistrationPage === undefined) {
enableRegistrationPage = !(Settings.ldap?.enable || Settings.saml?.enable || Settings.oidc?.enable)
}
if (enableRegistrationPage) {
const { default: router } = await import('./app/src/RegistrationPageRouter.mjs')
Settings.enableRegistrationPage = true
if (process.env.OVERLEAF_ALLOWED_REGISTRATION_EMAIL_DOMAINS) {
Settings.allowedRegistrationEmailDomains = process.env.OVERLEAF_ALLOWED_REGISTRATION_EMAIL_DOMAINS
.split(/[,\s]+/)
.filter(Boolean)
}
RegistrationPageModule = {
name: 'registration-page',
router: router,
}
}
export default RegistrationPageModule