Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions components/account_management/NewEmailForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
import ThemedButton from '../ThemedButton.vue';
import { useI18n } from 'vue-i18n';
import joi from 'joi';
import { normalizeEmail } from '../../email';
const props = defineProps<{
user?: Record<string, string>;
}>();
Expand Down Expand Up @@ -124,6 +125,9 @@
e.preventDefault();
newEmailError.value = '';
if (!props.user || !props.user.uuid) return;
// Canonicalize before validating so the verification step and the confirmation
// message use the address the change request was filed under.
newEmail.value = normalizeEmail(newEmail.value);
if (!newEmail.value || !isValidEmail(newEmail.value)) {
newEmailError.value = t('security.newemailinvalid');
return;
Expand Down
4 changes: 4 additions & 0 deletions components/registration/AuthForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@
import { Heading, Input, Button } from '@libretexts/davis-vue';
import PasswordStrengthMeter from '@components/PasswordStrengthMeter.vue';
import { getPasswordStrength } from '@renderer/utils/auth';
import { normalizeEmail } from '../../email';

defineProps<{
loginURL: string;
Expand Down Expand Up @@ -233,6 +234,9 @@
async function submitForm(e: Event) {
e.preventDefault();
resetFormErrors();
// Canonicalize before validating so the address shown on the verification step is
// the one the account was actually created with.
email.value = normalizeEmail(email.value);
if (!validateForm()) return;
loading.value = true;
try {
Expand Down
18 changes: 18 additions & 0 deletions email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Canonical email address normalization, shared by the server and the browser bundle.
*
* The contract is: trim surrounding whitespace, apply Unicode NFC so visually identical
* addresses have identical bytes, then lowercase with the locale-invariant `toLowerCase`
* (never `toLocaleLowerCase`, which would fold `I` differently under a Turkish locale).
*
* Diacritics are preserved on purpose: `jose@example.com` and `josé@example.com` are
* distinct addresses per RFC 5321 and are treated as such here. Gmail-style dot and
* plus-tag stripping is also deliberately absent, since both are significant at most
* providers and folding them would merge unrelated accounts.
*/
export function normalizeEmail(value: unknown): string {
if (typeof value !== 'string') {
return '';
}
return value.trim().normalize('NFC').toLowerCase();
}
86 changes: 86 additions & 0 deletions migrations/0003_normalize_email_case.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- 0003_normalize_email_case.sql
-- Canonicalizes stored email addresses and pins the collation of every email column.
--
-- The application now writes addresses in one canonical form (trim, Unicode NFC,
-- lowercase) via normalizeEmail() in email.ts, enforced at the boundary by
-- emailValidator in server/validators/shared.ts and again by the @BeforeValidate hooks
-- on User, EmailVerification, and EmailEvent. This file brings existing rows in line
-- and makes the database enforce the same policy.
--
-- Diacritics are preserved on purpose: jose@x.com and jose@x.com (with an accent) are
-- distinct addresses, so the target collation is case-insensitive but ACCENT-SENSITIVE.
--
-- RUN THIS FILE IN THREE PASSES, WITH A HUMAN BETWEEN THEM. Do not pipe it in whole.

-- ---------------------------------------------------------------------------
-- PASS 0 - survey. Record the answers before changing anything.
-- ---------------------------------------------------------------------------

-- utf8mb4_0900_as_ci in PASS 3 requires MySQL 8.0 or newer.
SELECT VERSION();

-- What the columns are today. A *_ci collation here means no case-variant duplicates
-- can already exist, so PASS 1 returning zero rows is confirmation rather than luck.
SHOW FULL COLUMNS FROM `users` LIKE 'email';
SHOW FULL COLUMNS FROM `email_verifications` LIKE 'email';
SHOW FULL COLUMNS FROM `email_events` LIKE 'email';

-- Addresses holding characters outside ASCII. Expected to be zero or near it. MySQL
-- cannot apply Unicode NFC, so any rows listed here are normalized by the model hook
-- the next time the record is written; note the count and move on.
SELECT `uuid`, `email` FROM `users` WHERE `email` <> CONVERT(`email` USING ASCII);

-- ---------------------------------------------------------------------------
-- PASS 1 - collision detection. If this returns ANY rows, STOP.
-- ---------------------------------------------------------------------------
-- Two accounts differing only by case cannot both survive the lowercase backfill, and
-- the unique index on users.email will reject the UPDATE. Resolve each pair by hand:
-- decide which account is real, then migrate or disable the other. Do not automate it,
-- because the wrong choice locks a real person out of their account.

SELECT LOWER(`email`) AS normalized, COUNT(*) AS n, GROUP_CONCAT(`uuid`) AS uuids
FROM `users`
GROUP BY normalized
HAVING n > 1;

-- Useful when triaging a collision: which of the pair has been used most recently.
-- SELECT uuid, email, last_access, created_at, disabled
-- FROM users WHERE LOWER(email) = '<normalized address from above>';

-- ---------------------------------------------------------------------------
-- PASS 2 - backfill. Only once PASS 1 returns zero rows.
-- ---------------------------------------------------------------------------
-- email_verifications rows expire after 24 hours and email_events has no unique
-- constraint on email alone, so neither needs a collision pre-check.
Comment on lines +53 to +54

UPDATE `users` SET `email` = LOWER(`email`) WHERE `email` <> BINARY LOWER(`email`);
UPDATE `email_verifications` SET `email` = LOWER(`email`) WHERE `email` <> BINARY LOWER(`email`);
UPDATE `email_events` SET `email` = LOWER(`email`) WHERE `email` <> BINARY LOWER(`email`);

-- Confirm. All three must return 0.
SELECT COUNT(*) FROM `users` WHERE `email` <> BINARY LOWER(`email`);
SELECT COUNT(*) FROM `email_verifications` WHERE `email` <> BINARY LOWER(`email`);
SELECT COUNT(*) FROM `email_events` WHERE `email` <> BINARY LOWER(`email`);

-- ---------------------------------------------------------------------------
-- PASS 3 - collation. Only once PASS 2 reports zero on all three counts.
-- ---------------------------------------------------------------------------
-- utf8mb4_0900_as_ci is accent-sensitive and case-insensitive, which is exactly the
-- application policy. On MySQL 5.7, use utf8mb4_bin instead (exact match, correct
-- because the application always writes lowercase). Do NOT substitute
-- utf8mb4_unicode_ci or utf8mb4_general_ci: both are accent-INSENSITIVE and would
-- collapse jose@x.com and jose@x.com-with-an-accent into one address.

ALTER TABLE `users`
MODIFY `email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_ci NOT NULL;

ALTER TABLE `email_verifications`
MODIFY `email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_ci NOT NULL;

ALTER TABLE `email_events`
MODIFY `email` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_ci NOT NULL;

-- Note on the test database: connectDatabase() builds it with sync({ alter: true })
-- from the model decorators, which does not reproduce these collations. Tests must
-- therefore assert application-level normalization rather than lean on the database
-- folding case for them.
1 change: 1 addition & 0 deletions migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ and production databases.
| --- | --- | --- | --- |
| `0001_users_search_indexes.sql` | | | |
| `0002_api_user_password_write_permission.sql` | | | |
| `0003_normalize_email_case.sql` | | | |
4 changes: 4 additions & 0 deletions pages/passwordrecovery/+Page.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
import { IconCircleArrowLeft } from '@tabler/icons-vue';
import { Input, Button } from '@libretexts/davis-vue';
import { usePageProps } from '@renderer/usePageProps';
import { normalizeEmail } from '../../email';

const props = usePageProps<{
source: string;
Expand Down Expand Up @@ -117,6 +118,9 @@
async function submitForm(e: Event) {
e.preventDefault();
resetFormErrors();
// Canonicalize before validating so the confirmation screen echoes the address the
// reset link was actually sent to.
email.value = normalizeEmail(email.value);
if (!validateForm()) return;
loading.value = true;
try {
Expand Down
17 changes: 11 additions & 6 deletions server/controllers/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { EmailVerificationController } from './EmailVerificationController';
import { MailController } from './MailController';
import { emailTemplates } from '../emails/templates';
import { DEFAULT_AVATAR, UUID_V4_REGEX } from './UserController';
import { DEFAULT_FIRST_NAME, DEFAULT_LAST_NAME, getPrettyDefaultAccessString, getProductionURL } from '../helpers';
import { DEFAULT_FIRST_NAME, DEFAULT_LAST_NAME, getPrettyDefaultAccessString, getProductionURL, normalizeEmail } from '../helpers';
import errors from '../errors';
import type { CookieOptions, Request, Response } from 'express';
import type {
Expand Down Expand Up @@ -415,12 +415,14 @@ export class AuthController {
.map((l) => l.get('main_url'));
const foundLib = userLibs?.find((u) => u === `https://${source}`);

const principalEmail = normalizeEmail(principal.attributes?.email);

const payload = {
first_name: principal.attributes?.first_name ?? principal.attributes?.given_name,
last_name: principal.attributes?.last_name ?? principal.attributes?.family_name,
email: principal.attributes?.email,
email: principalEmail || undefined,
picture: principal.attributes?.picture,
educational: /(?<=.*?)@.*?\.edu/.test(principal.attributes?.email),
educational: /(?<=.*?)@.*?\.edu/.test(principalEmail),
};

console.log(`CAS Bridge authentication successful for user ${resolvedUUID} (${foundUser.get('email')})`);
Expand Down Expand Up @@ -772,7 +774,7 @@ export class AuthController {
}
};

return email;
return email ? normalizeEmail(email) : null;
};

const email = getEmailFromPayload(body.clientName, payload);
Expand Down Expand Up @@ -892,7 +894,10 @@ export class AuthController {
}
return 'external_subject_id';
};
const attrMatch = { [getAttrMatchKey(username)]: username };
const attrMatchKey = getAttrMatchKey(username);
const attrMatch = {
[attrMatchKey]: attrMatchKey === 'email' ? normalizeEmail(username) : username,
};

const foundUser = await User.findOne({
where: attrMatch,
Expand Down Expand Up @@ -1264,7 +1269,7 @@ export class AuthController {
where: {
[Op.or]: [
{ uuid: userID },
{ email: userID }
{ email: normalizeEmail(userID) }
]
},
attributes: ['uuid']
Expand Down
5 changes: 3 additions & 2 deletions server/controllers/EmailVerificationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Op } from 'sequelize';
import { EmailVerification } from '../models';
import { MailController } from './MailController';
import { emailTemplates } from '../emails/templates';
import { normalizeEmail } from '../helpers';

export class EmailVerificationController {
static generateCode() {
Expand All @@ -14,7 +15,7 @@ export class EmailVerificationController {

const verification = await EmailVerification.create({
user_id: uuid,
email,
email: normalizeEmail(email),
code: EmailVerificationController.generateCode(),
expires_at: verifyExpiry,
});
Expand All @@ -26,7 +27,7 @@ export class EmailVerificationController {
const foundVerification = await EmailVerification.findOne({
where: {
[Op.and]: [
{ email },
{ email: normalizeEmail(email) },
{ code },
],
},
Expand Down
3 changes: 2 additions & 1 deletion server/controllers/SnsEventController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Request, Response } from 'express';
import MessageValidator from 'sns-validator';
import errors from '@server/errors';
import { normalizeEmail } from '@server/helpers';
import { EmailEvent, type EmailEventType } from '@server/models/EmailEvent';
import { User } from '@server/models/User';
import { recordConsentChange } from '@server/services/marketingConsent';
Expand Down Expand Up @@ -180,7 +181,7 @@ export class SnsEventController {

for (const rcpt of recipients) {
try {
const email = rcpt.email.toLowerCase();
const email = normalizeEmail(rcpt.email);
const user = await User.findOne({ where: { email } });

/**
Expand Down
18 changes: 13 additions & 5 deletions server/controllers/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { AuthController } from './AuthController';
import { DeleteAccountRequest } from '@server/models/DeleteAccountRequest';
import { EventSubscriberEmitter } from '@server/events/EventSubscriberEmitter';
import { UserNote } from '../models/UserNote';
import { generateSecureRandomString } from '@server/helpers';
import { generateSecureRandomString, normalizeEmail } from '@server/helpers';
import type { ApplicationLaunchpadVisibility } from '@server/types/applications';

export const DEFAULT_AVATAR = 'https://cdn.libretexts.net/DefaultImages/avatar.png';
Expand Down Expand Up @@ -107,12 +107,17 @@ function buildUserSearchCriteria(query: string): { match: string; relevance: str
const exact = sequelize.escape(query);
const prefixOf = (value: string) => sequelize.escape(`${escapeLikePattern(value)}%`);
const containsOf = (value: string) => sequelize.escape(`%${escapeLikePattern(value)}%`);
// Addresses are stored in canonical form, so anything compared against `email` is
// normalized first. Name and UUID matching keeps the raw input.
const emailExact = sequelize.escape(normalizeEmail(query));
const emailPrefixOf = (value: string) => prefixOf(normalizeEmail(value));
const emailContainsOf = (value: string) => containsOf(normalizeEmail(value));
const fullName = 'CONCAT(`User`.`first_name`, \' \', `User`.`last_name`)';

const exactClause = `(\`User\`.\`email\` = ${exact} OR \`User\`.\`uuid\` = ${exact})`;
const exactClause = `(\`User\`.\`email\` = ${emailExact} OR \`User\`.\`uuid\` = ${exact})`;

const tokenClause = `(${tokens.map((token) => (
`(\`User\`.\`email\` LIKE ${containsOf(token)}`
`(\`User\`.\`email\` LIKE ${emailContainsOf(token)}`
+ ` OR \`User\`.\`first_name\` LIKE ${containsOf(token)}`
+ ` OR \`User\`.\`last_name\` LIKE ${containsOf(token)}`
+ ` OR \`User\`.\`uuid\` LIKE ${containsOf(token)})`
Expand All @@ -131,7 +136,7 @@ function buildUserSearchCriteria(query: string): { match: string; relevance: str

const relevance = 'CASE'
+ ` WHEN ${exactClause} THEN 100`
+ ` WHEN \`User\`.\`email\` LIKE ${prefixOf(query)} THEN 80`
+ ` WHEN \`User\`.\`email\` LIKE ${emailPrefixOf(query)} THEN 80`
+ ` WHEN ${fullName} LIKE ${prefixOf(query)}${bothNamesClause} THEN 70`
+ ` WHEN ${anyNamePrefixClause} THEN 50`
// Everything left matched somewhere inside a field rather than at its start.
Expand Down Expand Up @@ -837,7 +842,10 @@ export class UserController {
}
return 'external_subject_id';
};
const attrMatch = { [getAttrMatchKey(username)]: username };
const attrMatchKey = getAttrMatchKey(username);
const attrMatch = {
[attrMatchKey]: attrMatchKey === 'email' ? normalizeEmail(username) : username,
};

const foundUser = await User.findOne({
where: {
Expand Down
3 changes: 3 additions & 0 deletions server/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { NextFunction, Request, Response } from 'express';
import errors from './errors';
import type { Application } from './types/applications';
import { normalizeEmail } from '../email';

export { normalizeEmail };

type APIFunction = (request: Request, response: Response, next: NextFunction) => Response | void;
type AsyncAPIFunction = (request: Request, response: Response, next: NextFunction) => Promise<Response | void>;
Expand Down
13 changes: 13 additions & 0 deletions server/models/EmailEvent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AllowNull,
BeforeValidate,
BelongsTo,
Column,
CreatedAt,
Expand All @@ -12,6 +13,7 @@ import {
UpdatedAt,
} from 'sequelize-typescript';
import { User } from './User';
import { normalizeEmail } from '../../email';

export type EmailEventType = 'HARD_BOUNCE' | 'SOFT_BOUNCE' | 'COMPLAINT';

Expand All @@ -29,6 +31,17 @@ export type EmailEventType = 'HARD_BOUNCE' | 'SOFT_BOUNCE' | 'COMPLAINT';
tableName: 'email_events',
})
export class EmailEvent extends Model {
/**
* Guarantees the canonical form reaches storage no matter which code path wrote the
* record. Query predicates are normalized separately at their call sites.
*/
@BeforeValidate
static normalizeEmailAddress(instance: EmailEvent) {
if (typeof instance.email === 'string') {
instance.email = normalizeEmail(instance.email);
}
}

@PrimaryKey
@AutoIncrement
@Column(DataType.BIGINT)
Expand Down
13 changes: 13 additions & 0 deletions server/models/EmailVerification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AllowNull,
BeforeValidate,
Column,
CreatedAt,
DataType,
Expand All @@ -9,12 +10,24 @@ import {
UpdatedAt,
} from 'sequelize-typescript';
import { User } from './User';
import { normalizeEmail } from '../../email';

@Table({
timestamps: true,
tableName: 'email_verifications',
})
export class EmailVerification extends Model {
/**
* Guarantees the canonical form reaches storage no matter which code path wrote the
* record. Query predicates are normalized separately at their call sites.
*/
@BeforeValidate
static normalizeEmailAddress(instance: EmailVerification) {
if (typeof instance.email === 'string') {
instance.email = normalizeEmail(instance.email);
}
}

@ForeignKey(() => User)
@AllowNull(false)
@Column(DataType.STRING)
Expand Down
Loading
Loading