diff --git a/components/registration/VerifyEmail.vue b/components/registration/VerifyEmailForm.vue similarity index 98% rename from components/registration/VerifyEmail.vue rename to components/registration/VerifyEmailForm.vue index edb1b84..99e1c2e 100644 --- a/components/registration/VerifyEmail.vue +++ b/components/registration/VerifyEmailForm.vue @@ -120,7 +120,7 @@ } loading.value = true; try { - await axios.post('/auth/verify-email', { + await axios.post('/auth/verify-email-code', { email: props.email, code: code.value, }); diff --git a/locales/en-us.json b/locales/en-us.json index 41f1edd..4ade662 100644 --- a/locales/en-us.json +++ b/locales/en-us.json @@ -107,6 +107,19 @@ "instructor_guide": "You can now use LibreOne to complete your instructor verification. This will allow LibreTexts to give you advanced access to services reserved for educators only.", "continue_verification": "Continue to Instructor Verification" }, + "email_verification": { + "header": "Email Verification", + "loading": "Checking your verification token...", + "verify_thanks": "Thanks for helping to keep our community safe!", + "success_header": "Email Verified!", + "success_tagline": "Your email has been successfully verified. You can now sign in to LibreOne with your email and password.", + "continue_to_signin": "Continue to Sign In", + "error_header": "Verification Failed", + "error_invalid": "Oops, it looks like your verification token is invalid. If you already entered your six-digit verification code during registration, you can safely ignore this message and sign in to LibreOne.", + "error_expired": "Oops, it looks like your verification token has expired. Please request a new verification email below.", + "resend_verification": "Resend Verification Email", + "resend_success": "Success! A new verification email has been sent to your email address. You can safely close this page now." + }, "password": { "strength": "Strength", "short": "Too short", diff --git a/pages/register/+Page.vue b/pages/register/+Page.vue index c6ee4c7..c2a19ae 100644 --- a/pages/register/+Page.vue +++ b/pages/register/+Page.vue @@ -42,8 +42,8 @@ import { import AuthForm from "@components/registration/AuthForm.vue"; import { usePageContext } from "@renderer/usePageContext"; import { usePageProps } from "@renderer/usePageProps"; -const VerifyEmail = defineAsyncComponent( - () => import("@components/registration/VerifyEmail.vue") +const VerifyEmailForm = defineAsyncComponent( + () => import("@components/registration/VerifyEmailForm.vue") ); const props = usePageProps<{ @@ -66,7 +66,7 @@ onMounted(() => { const componentProps = computed(() => { switch (stage.value) { - case VerifyEmail: { + case VerifyEmailForm: { return { email: email.value }; } default: { @@ -76,7 +76,7 @@ const componentProps = computed(() => { }); const componentEvents = computed(() => { switch (stage.value) { - case VerifyEmail: { + case VerifyEmailForm: { return {}; } default: { @@ -92,6 +92,6 @@ const componentEvents = computed(() => { */ function handleInitialRegistrationComplete(resEmail: string) { email.value = resEmail; - stage.value = VerifyEmail; + stage.value = VerifyEmailForm; } diff --git a/pages/verify-email/+Page.vue b/pages/verify-email/+Page.vue new file mode 100644 index 0000000..56eed15 --- /dev/null +++ b/pages/verify-email/+Page.vue @@ -0,0 +1,163 @@ + + + diff --git a/pages/verify-email/+onBeforeRender.ts b/pages/verify-email/+onBeforeRender.ts new file mode 100644 index 0000000..50662a9 --- /dev/null +++ b/pages/verify-email/+onBeforeRender.ts @@ -0,0 +1,23 @@ +import type { PageContextServer } from 'vike/types'; + +/** + * Reads search parameters provided in the URL and transforms them to component props. + * + * @param pageContext - The current server-side page rendering context. + * @returns New pageContext object with parsed props. + */ +export default async function onBeforeRender(pageContext: PageContextServer) { + const searchParams = pageContext.urlParsed.search; + let token: string | null = null; + if (searchParams.token) { + token = searchParams.token; + } + + return { + pageContext: { + pageProps: { + ...(token && { token }), + }, + }, + }; +} diff --git a/server/controllers/AuthController.ts b/server/controllers/AuthController.ts index 80e894e..a39131b 100644 --- a/server/controllers/AuthController.ts +++ b/server/controllers/AuthController.ts @@ -28,7 +28,6 @@ import errors from '../errors'; import { CookieOptions, Request, Response } from 'express'; import type { RegisterBody, - VerifyEmailBody, CompleteLoginQuery, InitLoginQuery, InitResetPasswordBody, @@ -41,6 +40,9 @@ import type { BackChannelSLOBody, BackChannelSLOQuery, CreateUserFromExternalIdPBody, + VerifyEmailTokenBody, + VerifyEmailCodeBody, + ResendVerificationEmailBody, } from '../types/auth'; import { LoginEventController } from '@server/controllers/LoginEventController'; import { XMLParser } from 'fast-xml-parser'; @@ -493,17 +495,18 @@ export class AuthController { const newUser = await User.create({ uuid: uuidv4(), email: props.email, + email_verified: false, // Email is not yet verified password: hashed, first_name: DEFAULT_FIRST_NAME, last_name: DEFAULT_LAST_NAME, - disabled: true, + disabled: false, expired: false, legacy: false, ip_address: ip, verify_status: 'not_attempted', registration_type: 'self', }); - const verifyCode = await verificationController.createVerification( + const { code, token } = await verificationController.createVerification( newUser.get('uuid'), props.email, ); @@ -512,7 +515,8 @@ export class AuthController { const emailRes = await verificationController.sendEmailVerificationMessage( mailSender, props.email, - verifyCode, + code, + token, ); mailSender.destroy(); if (!emailRes) { @@ -541,20 +545,21 @@ export class AuthController { * @param res - Outgoing API response. * @returns The fulfilled API response. */ - public async verifyRegistrationEmail(req: Request, res: Response): Promise { - const { email, code } = req.body as VerifyEmailBody; + public async verifyRegistrationEmailCode(req: Request, res: Response): Promise { + const { email, code } = req.body as VerifyEmailCodeBody; - const foundVerification = await new EmailVerificationController().checkVerification(email, code); - if (!foundVerification || !foundVerification.uuid) { + const foundUser = await User.findOne({ where: { email } }); + if (!foundUser) { return errors.badRequest(res); } - const foundUser = await User.findOne({ where: { uuid: foundVerification.uuid } }); - if (!foundUser) { + const foundVerification = await new EmailVerificationController().checkVerificationCode(foundUser.uuid, code); + if (!foundVerification || !foundVerification.uuid) { return errors.badRequest(res); } - foundUser.disabled = false; + + foundUser.email_verified = true; await foundUser.save(); // Create a short-lived local session @@ -563,12 +568,92 @@ export class AuthController { await this.createAndAttachLocalSession(res, foundUser.uuid, undefined, 30); return res.send({ + success: true, data: { uuid: foundUser.uuid, }, }); } + /** + * Validates a provided email verification link token, then marks the user's email as verified. + * Intended for async email verification via link vs. code entry. + * + * @param req - Incoming API request. + * @param res - Outgoing API response. + * @returns The fulfilled API response. + */ + public async verifyRegistrationEmailToken(req: Request, res: Response): Promise { + const { token } = req.body as VerifyEmailTokenBody; + + const foundVerification = await new EmailVerificationController().checkVerificationToken(token); + if (!foundVerification || !foundVerification.uuid) { + return errors.badRequest(res, "Invalid verification token!"); + } + + // Verification token was found, but it's expired + if (foundVerification.expired) { + return res.send({ + success: false, + error: "Verification token has expired. Please request a new verification email.", + data: { + uuid: foundVerification.uuid, + }, + }) + } + + const foundUser = await User.findOne({ where: { uuid: foundVerification.uuid } }); + if (!foundUser) { + return errors.badRequest(res); + } + + foundUser.email_verified = true; + await foundUser.save(); + + return res.send({ + success: true, + data: { + uuid: foundUser.uuid, + }, + }) + } + + public async resendVerificationEmail(req: Request, res: Response): Promise { + const { uuid } = req.body as ResendVerificationEmailBody; + + const foundUser = await User.findOne({ where: { uuid } }); + if (!foundUser) { + return errors.badRequest(res); + } + + const verificationController = new EmailVerificationController(); + const mailSender = new MailController(); + if (!mailSender.isReady()) { + throw new Error('No mail sender available to issue email verification!'); + } + + const { token } = await verificationController.createVerification( + foundUser.get('uuid'), + foundUser.get('email'), + ); + + // Send email verification + const emailRes = await verificationController.sendEmailVerificationMessage( + mailSender, + foundUser.get('email'), + null, // Token only for resends + token, + ); + + mailSender.destroy(); + + if (!emailRes) { + throw new Error('Unable to send email verification!'); + } + + return res.send({ success: true }); + } + /** * Activates a new user after onboarding has been completed, then generates a JWT to use * to create a new SSO session where necessary. @@ -793,6 +878,7 @@ export class AuthController { uuid: uuidv4(), external_subject_id: payload.sub, email, + email_verified: true, first_name: givenName?.trim() ?? DEFAULT_FIRST_NAME, last_name: familyName?.trim() ?? DEFAULT_LAST_NAME, avatar: payload.picture || DEFAULT_AVATAR, @@ -811,6 +897,7 @@ export class AuthController { const updated = await foundUser.update({ external_subject_id: payload.sub, email, + email_verified: true, first_name: givenName?.trim() ?? DEFAULT_FIRST_NAME, last_name: familyName?.trim() ?? DEFAULT_LAST_NAME, avatar: payload.picture || DEFAULT_AVATAR, @@ -916,6 +1003,22 @@ export class AuthController { }); } + if (foundUser.email_verified === false) { + const redirectParams = new URLSearchParams({ + redirectCASServiceURI: req.query.service ? (req.query.service as string) : CAS_LOGIN, + }); + return res.send({ + interrupt: true, + block: true, + ssoEnabled: true, + message: 'We need to verify your email address before you can continue. Please use the link below to begin the verification process.', + autoRedirect: true, + links: { + 'Go': `${SELF_BASE}/api/v1/auth/login?${redirectParams.toString()}`, + }, + }); + } + // try { const timestamp = new Date(); diff --git a/server/controllers/EmailVerificationController.ts b/server/controllers/EmailVerificationController.ts index d389644..ecb89a0 100644 --- a/server/controllers/EmailVerificationController.ts +++ b/server/controllers/EmailVerificationController.ts @@ -1,60 +1,108 @@ -import { Op } from 'sequelize'; -import { EmailVerification } from '../models'; -import { MailController } from './MailController'; +import { Op } from "sequelize"; +import { EmailVerification } from "../models"; +import { MailController } from "./MailController"; +import crypto from "crypto"; export class EmailVerificationController { static generateCode() { return Math.floor(Math.random() * (999999 - 100000 + 1)) + 100000; } + static generateToken() { + return crypto.randomBytes(32).toString("hex"); + } + public async createVerification(uuid: string, email: string) { const verifyExpiry = new Date(); verifyExpiry.setDate(verifyExpiry.getDate() + 1); const verification = await EmailVerification.create({ user_id: uuid, - email, code: EmailVerificationController.generateCode(), + token: EmailVerificationController.generateToken(), expires_at: verifyExpiry, }); - return verification.get('code'); + return { + code: verification.get("code"), + token: verification.get("token"), + }; + } + + public async checkVerificationCode(user_id: string, code: number) { + const now = new Date(); + const foundVerification = await EmailVerification.findOne({ + where: { + [Op.and]: [{ user_id }, { code }], + }, + }); + if (!foundVerification || foundVerification.expires_at < now) { + return null; + } + + const response = { + uuid: foundVerification.get("user_id"), + }; + + await foundVerification.destroy(); + return response; } - public async checkVerification(email: string, code: number) { + public async checkVerificationToken(token: string) { const now = new Date(); const foundVerification = await EmailVerification.findOne({ where: { - [Op.and]: [ - { email }, - { code }, - ], + token, }, }); - if (!foundVerification || foundVerification.expires_at < now || foundVerification.code !== code) { + + if (!foundVerification) { return null; } + // Token was found, but it's expired + const isExpired = foundVerification.expires_at < now; + const response = { - uuid: foundVerification.get('user_id'), - email: foundVerification.get('email'), + uuid: foundVerification.get("user_id"), + expired: isExpired, }; await foundVerification.destroy(); return response; } - public async sendEmailVerificationMessage(mailSender: MailController, email: string, code: number) { + public async sendEmailVerificationMessage( + mailSender: MailController, + email: string, + code: number | null, + token: string, + ) { if (!mailSender || !mailSender.isReady()) { - throw new Error('No mail sender available to issue email verification!'); + throw new Error("No mail sender available to issue email verification!"); + } + + let instructions = ""; + if (code) { + instructions = ` +

Thank you for registering with LibreOne! To complete your registration, please use the verification code below or click the link to verify your email address:

+

${code}

+ `; + } else { + instructions = ` +

Thank you for registering with LibreOne! To finish verifying your email address, please click the link below:

+ `; } const emailRes = await mailSender.send({ destination: { to: [email] }, - subject: `LibreOne Verification Code: ${code}`, + subject: `Your Verification ${code ? "Code" : "Link for LibreOne"}${code ? `: ${code}` : ""}`, htmlContent: ` -

Hello there,

-

Please verify your email address by entering this code:

-

${code}

+

Hi there,

+ ${instructions} +
+

Verify Email Address

+
+

This ${code ? `code and link` : `link`} will expire in 24 hours.

If this wasn't you, you can safely ignore this email.

Best,

The LibreTexts Team

diff --git a/server/models/EmailVerification.ts b/server/models/EmailVerification.ts index 645678e..bc138bd 100644 --- a/server/models/EmailVerification.ts +++ b/server/models/EmailVerification.ts @@ -20,14 +20,14 @@ export class EmailVerification extends Model { @Column(DataType.STRING) declare user_id: string; - @AllowNull(false) - @Column(DataType.STRING) - declare email: string; - @AllowNull(false) @Column(DataType.INTEGER) declare code: number; + @AllowNull(false) + @Column(DataType.STRING) + declare token: string; + @AllowNull(false) @Column(DataType.DATE) declare expires_at: Date; diff --git a/server/models/User.ts b/server/models/User.ts index 8e7cc96..5810cd3 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -28,10 +28,11 @@ import { VerificationRequest } from './VerificationRequest'; import { Session } from './Session'; import { UserNote } from './UserNote'; import { UserLicenseEntitlement } from './UserLicenseEntitlement'; +import { EmailVerification } from './EmailVerification'; @DefaultScope(() => ({ attributes: { - exclude: ['password', 'ip_address', 'email_verify_code'], + exclude: ['password', 'ip_address', 'email_verifications'], }, })) @Table({ @@ -95,6 +96,9 @@ export class User extends Model { @Column(DataType.DATE) declare disabled_date: Date | null; + @Column(DataType.BOOLEAN) + declare email_verified: boolean; + @Default(false) @Column(DataType.BOOLEAN) declare expired: boolean; @@ -172,6 +176,9 @@ export class User extends Model { @HasMany(() => AccessRequest) access_requests?: Array; + @HasMany(() => EmailVerification) + email_verifications?: Array; + @HasMany(() => LoginEvent) login_events?: Array; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index a9e53f4..d2b2c2a 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -27,9 +27,19 @@ authRouter.route('/register').post( catchInternal((req, res) => controller.register(req, res)), ); -authRouter.route('/verify-email').post( - validate(AuthValidator.verifyEmailSchema, 'body'), - catchInternal((req, res) => controller.verifyRegistrationEmail(req, res)), +authRouter.route('/verify-email-code').post( + validate(AuthValidator.verifyEmailCodeSchema, 'body'), + catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)), +); + +authRouter.route('/verify-email-token').post( + validate(AuthValidator.verifyEmailTokenSchema, 'body'), + catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)), +); + +authRouter.route('/resend-verification-email').post( + validate(AuthValidator.resendVerificationEmailSchema, 'body'), + catchInternal((req, res) => controller.resendVerificationEmail(req, res)), ); authRouter.route('/complete-registration').post( diff --git a/server/tests/auth.spec.ts b/server/tests/auth.spec.ts index 02c0ec9..71fdd94 100644 --- a/server/tests/auth.spec.ts +++ b/server/tests/auth.spec.ts @@ -92,7 +92,7 @@ describe('Authentication and Authorization', async () => { ); const response = await request(server) - .post('/api/v1/auth/verify-email') + .post('/api/v1/auth/verify-email-code') .send({ email: 'info@libretexts.org', code: verifyCode }); expect(response.status).to.equal(200); @@ -113,7 +113,7 @@ describe('Authentication and Authorization', async () => { ); const response = await request(server) - .post('/api/v1/auth/verify-email') + .post('/api/v1/auth/verify-email-code') .send({ email: 'info@libretexts.org', code: 101101 }); expect(response.status).to.equal(400); diff --git a/server/types/auth.ts b/server/types/auth.ts index 1776b7d..21cdcef 100644 --- a/server/types/auth.ts +++ b/server/types/auth.ts @@ -15,11 +15,19 @@ export type RegisterBody = { password: string; }; -export type VerifyEmailBody = { +export type VerifyEmailCodeBody = { email: string; code: number; }; +export type VerifyEmailTokenBody = { + token: string; +}; + +export type ResendVerificationEmailBody = { + uuid: string; +}; + export type CreateUserFromExternalIdPBody = { profileAttributes: CreateUserFromExternalIdPBodyProfileAttributes; clientName: string; diff --git a/server/validators/auth.ts b/server/validators/auth.ts index 69fedfc..293d699 100644 --- a/server/validators/auth.ts +++ b/server/validators/auth.ts @@ -64,11 +64,19 @@ export const autoProvisionUserSchema = joi.object({ time_zone: timeZoneValidator.required(), }); -export const verifyEmailSchema = joi.object({ +export const verifyEmailCodeSchema = joi.object({ email: joi.string().email().required(), code: joi.number().integer().min(100000).max(999999).required(), }); +export const verifyEmailTokenSchema = joi.object({ + token: joi.string().length(64).required(), +}); + +export const resendVerificationEmailSchema = joi.object({ + uuid: joi.string().uuid().required(), +}); + export const initLoginQuerySchema = joi.object({ redirectURI: joi.string().uri({ relativeOnly: true }), redirectCASServiceURI: joi.string().uri(),