Skip to content

feat(Users): verify email links#222

Open
jakeaturner wants to merge 1 commit intomainfrom
verify-email
Open

feat(Users): verify email links#222
jakeaturner wants to merge 1 commit intomainfrom
verify-email

Conversation

@jakeaturner
Copy link
Collaborator

No description provided.

catchInternal((req, res) => controller.verifyRegistrationEmail(req, res)),
authRouter.route('/verify-email-code').post(
validate(AuthValidator.verifyEmailCodeSchema, 'body'),
catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)),

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 11 days ago

To fix this, we should add rate limiting middleware to the sensitive authorization routes in this router, particularly /verify-email-code (and likely other similar endpoints like /verify-email-token and /resend-verification-email). The standard way in an Express/TypeScript project is to use a well‑known library such as express-rate-limit, configure a limiter with sensible defaults (e.g., small number of attempts per time window for code verification), and plug it into the route’s middleware chain before the expensive controller logic, without changing any existing controller behavior.

Concretely, in server/routes/auth.ts:

  1. Add an import for express-rate-limit.
  2. Define one or more limiter instances, e.g. emailCodeLimiter, configured for short windows and low max to prevent brute‑force on email verification codes, and possibly a more general authLimiter for other auth endpoints.
  3. Apply emailCodeLimiter to the /verify-email-code route by inserting it into the .post() middleware list between validate(...) and catchInternal(...) (or immediately after validate, order between those two doesn’t materially change behavior). For consistency and improved security, we can also apply an appropriate limiter to /verify-email-token and /resend-verification-email, but we will not alter the controllers themselves or their return values.

This requires only adding the express-rate-limit import and definitions at the top of this file, and updating the route definitions where they are already shown.

Suggested changeset 2
server/routes/auth.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -11,6 +11,7 @@
 } from '../middleware';
 import { catchInternal } from '../helpers';
 import bodyParser from 'body-parser';
+import rateLimit from 'express-rate-limit';
 
 const authRouter = express.Router();
 const controller = new AuthController();
@@ -21,7 +22,27 @@
 const CAS_BRIDGE_SERVER_URL = (_casPrefix.startsWith('https://') || _casPrefix.startsWith('http://')) ? _casPrefix : `https://${_casPrefix}`;
 const SELF_URL = `${_selfDomainSafe}/api/v1/auth/cas-bridge`;
 
+const emailCodeLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 10, // limit each IP to 10 verification attempts per window
+  standardHeaders: true,
+  legacyHeaders: false,
+});
 
+const emailTokenLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000,
+  max: 30,
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
+const resendVerificationEmailLimiter = rateLimit({
+  windowMs: 60 * 60 * 1000, // 1 hour
+  max: 5, // limit resends to 5 per hour
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
 authRouter.route('/register').post(
   validate(AuthValidator.registerSchema, 'body'),
   catchInternal((req, res) => controller.register(req, res)),
@@ -29,16 +49,19 @@
 
 authRouter.route('/verify-email-code').post(
   validate(AuthValidator.verifyEmailCodeSchema, 'body'),
+  emailCodeLimiter,
   catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)),
 );
 
 authRouter.route('/verify-email-token').post(
   validate(AuthValidator.verifyEmailTokenSchema, 'body'),
+  emailTokenLimiter,
   catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)),
 );
 
 authRouter.route('/resend-verification-email').post(
   validate(AuthValidator.resendVerificationEmailSchema, 'body'),
+  resendVerificationEmailLimiter,
   catchInternal((req, res) => controller.resendVerificationEmail(req, res)),
 );
 
EOF
@@ -11,6 +11,7 @@
} from '../middleware';
import { catchInternal } from '../helpers';
import bodyParser from 'body-parser';
import rateLimit from 'express-rate-limit';

const authRouter = express.Router();
const controller = new AuthController();
@@ -21,7 +22,27 @@
const CAS_BRIDGE_SERVER_URL = (_casPrefix.startsWith('https://') || _casPrefix.startsWith('http://')) ? _casPrefix : `https://${_casPrefix}`;
const SELF_URL = `${_selfDomainSafe}/api/v1/auth/cas-bridge`;

const emailCodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // limit each IP to 10 verification attempts per window
standardHeaders: true,
legacyHeaders: false,
});

const emailTokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});

const resendVerificationEmailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // limit resends to 5 per hour
standardHeaders: true,
legacyHeaders: false,
});

authRouter.route('/register').post(
validate(AuthValidator.registerSchema, 'body'),
catchInternal((req, res) => controller.register(req, res)),
@@ -29,16 +49,19 @@

authRouter.route('/verify-email-code').post(
validate(AuthValidator.verifyEmailCodeSchema, 'body'),
emailCodeLimiter,
catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)),
);

authRouter.route('/verify-email-token').post(
validate(AuthValidator.verifyEmailTokenSchema, 'body'),
emailTokenLimiter,
catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)),
);

authRouter.route('/resend-verification-email').post(
validate(AuthValidator.resendVerificationEmailSchema, 'body'),
resendVerificationEmailLimiter,
catchInternal((req, res) => controller.resendVerificationEmail(req, res)),
);

package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -69,7 +69,8 @@
     "vite": "^6.4.1",
     "vue": "^3.2.45",
     "vue-i18n": "^11.1.10",
-    "yargs": "^17.7.2"
+    "yargs": "^17.7.2",
+    "express-rate-limit": "^8.2.1"
   },
   "devDependencies": {
     "@aws-sdk/types": "^3.329.0",
EOF
@@ -69,7 +69,8 @@
"vite": "^6.4.1",
"vue": "^3.2.45",
"vue-i18n": "^11.1.10",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"express-rate-limit": "^8.2.1"
},
"devDependencies": {
"@aws-sdk/types": "^3.329.0",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.2.1 None
Copilot is powered by AI and may make mistakes. Always verify output.

authRouter.route('/verify-email-token').post(
validate(AuthValidator.verifyEmailTokenSchema, 'body'),
catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)),

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 11 days ago

In general, the fix is to introduce an Express‑compatible rate‑limiting middleware (for example, via express-rate-limit) and apply it to sensitive routes that perform authentication/authorization or other expensive operations. This middleware intercepts requests before they reach the controller, tracks request counts per client (typically keyed by IP), and rejects or delays excessive requests, mitigating denial‑of‑service and brute‑force attacks.

For this file, the least intrusive fix without changing existing functionality is:

  • Import a well‑known rate‑limiter (express-rate-limit).
  • Define one or more limiter instances with sensible defaults (e.g., separate, stricter limits for auth/verification endpoints).
  • Apply the limiter middleware to the affected routes; at minimum the route CodeQL flagged (/verify-email-token), and, following the same rationale, to adjacent registration/verification endpoints (/register, /verify-email-code, /resend-verification-email, /complete-registration, /external-provision), since they are also auth‑related and may be expensive.

Concretely in server/routes/auth.ts:

  1. Add an import for express-rate-limit near the top, without modifying existing imports.

  2. After router/controller initialization, create a limiter, e.g.:

    • const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false });

    These values are examples and can be tuned later; they preserve existing behavior for normal users while limiting abuse.

  3. Insert authRateLimiter in the middleware chains of the relevant routes, especially the flagged /verify-email-token handler, ensuring it appears before catchInternal so that rate‑limit responses are generated by the limiter itself and existing controller logic is untouched.

No existing controller logic, validation schemas, or helper functions need changes; we’re only extending middleware chains.

Suggested changeset 2
server/routes/auth.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
--- a/server/routes/auth.ts
+++ b/server/routes/auth.ts
@@ -11,10 +11,18 @@
 } from '../middleware';
 import { catchInternal } from '../helpers';
 import bodyParser from 'body-parser';
+import rateLimit from 'express-rate-limit';
 
 const authRouter = express.Router();
 const controller = new AuthController();
 
+const authRateLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 100, // limit each IP to 100 requests per windowMs
+  standardHeaders: true,
+  legacyHeaders: false,
+});
+
 const _selfDomain = process.env.DOMAIN || 'localhost:5001';
 const _selfDomainSafe = _selfDomain.startsWith('https://') ? _selfDomain : `https://${_selfDomain}`;
 const _casPrefix = process.env.CAS_BRIDGE_SERVER_URL || 'http://localhost:8443/cas';
@@ -23,32 +27,38 @@
 
 
 authRouter.route('/register').post(
+  authRateLimiter,
   validate(AuthValidator.registerSchema, 'body'),
   catchInternal((req, res) => controller.register(req, res)),
 );
 
 authRouter.route('/verify-email-code').post(
+  authRateLimiter,
   validate(AuthValidator.verifyEmailCodeSchema, 'body'),
   catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)),
 );
 
 authRouter.route('/verify-email-token').post(
+  authRateLimiter,
   validate(AuthValidator.verifyEmailTokenSchema, 'body'),
   catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)),
 );
 
 authRouter.route('/resend-verification-email').post(
+  authRateLimiter,
   validate(AuthValidator.resendVerificationEmailSchema, 'body'),
   catchInternal((req, res) => controller.resendVerificationEmail(req, res)),
 );
 
 authRouter.route('/complete-registration').post(
+  authRateLimiter,
   verifyAPIAuthentication,
   validate(AuthValidator.completeRegistrationSchema, 'body'),
   catchInternal((req, res) => controller.completeRegistration(req, res)),
 );
 
 authRouter.route('/external-provision').post(
+  authRateLimiter,
   verifyAPIAuthentication,
   ensureActorIsAPIUser,
   ensureAPIUserHasPermission(['users:write']),
EOF
@@ -11,10 +11,18 @@
} from '../middleware';
import { catchInternal } from '../helpers';
import bodyParser from 'body-parser';
import rateLimit from 'express-rate-limit';

const authRouter = express.Router();
const controller = new AuthController();

const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
});

const _selfDomain = process.env.DOMAIN || 'localhost:5001';
const _selfDomainSafe = _selfDomain.startsWith('https://') ? _selfDomain : `https://${_selfDomain}`;
const _casPrefix = process.env.CAS_BRIDGE_SERVER_URL || 'http://localhost:8443/cas';
@@ -23,32 +27,38 @@


authRouter.route('/register').post(
authRateLimiter,
validate(AuthValidator.registerSchema, 'body'),
catchInternal((req, res) => controller.register(req, res)),
);

authRouter.route('/verify-email-code').post(
authRateLimiter,
validate(AuthValidator.verifyEmailCodeSchema, 'body'),
catchInternal((req, res) => controller.verifyRegistrationEmailCode(req, res)),
);

authRouter.route('/verify-email-token').post(
authRateLimiter,
validate(AuthValidator.verifyEmailTokenSchema, 'body'),
catchInternal((req, res) => controller.verifyRegistrationEmailToken(req, res)),
);

authRouter.route('/resend-verification-email').post(
authRateLimiter,
validate(AuthValidator.resendVerificationEmailSchema, 'body'),
catchInternal((req, res) => controller.resendVerificationEmail(req, res)),
);

authRouter.route('/complete-registration').post(
authRateLimiter,
verifyAPIAuthentication,
validate(AuthValidator.completeRegistrationSchema, 'body'),
catchInternal((req, res) => controller.completeRegistration(req, res)),
);

authRouter.route('/external-provision').post(
authRateLimiter,
verifyAPIAuthentication,
ensureActorIsAPIUser,
ensureAPIUserHasPermission(['users:write']),
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -69,7 +69,8 @@
     "vite": "^6.4.1",
     "vue": "^3.2.45",
     "vue-i18n": "^11.1.10",
-    "yargs": "^17.7.2"
+    "yargs": "^17.7.2",
+    "express-rate-limit": "^8.2.1"
   },
   "devDependencies": {
     "@aws-sdk/types": "^3.329.0",
EOF
@@ -69,7 +69,8 @@
"vite": "^6.4.1",
"vue": "^3.2.45",
"vue-i18n": "^11.1.10",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"express-rate-limit": "^8.2.1"
},
"devDependencies": {
"@aws-sdk/types": "^3.329.0",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.2.1 None
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds token-based email verification links alongside the existing code-based verification system for user registration. Users can now verify their email addresses either by entering a 6-digit code or by clicking a link in their verification email.

Changes:

  • Added new API endpoints for token-based email verification and resending verification emails
  • Updated the EmailVerification model to include token field and removed the email field
  • Modified user registration flow to send both verification code and link in emails
  • Added new UI page for handling email verification via token link
  • Blocked login for users with unverified emails

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 19 comments.

Show a summary per file
File Description
server/validators/auth.ts Added validation schemas for token-based verification and resend endpoints
server/types/auth.ts Renamed and added type definitions for new verification methods
server/routes/auth.ts Added routes for verify-email-token and resend-verification-email endpoints
server/models/User.ts Added email_verified boolean field to track verification status
server/models/EmailVerification.ts Removed email field, added token field for link-based verification
server/controllers/EmailVerificationController.ts Added token generation and verification methods, updated email template
server/controllers/AuthController.ts Added new verification handlers and login blocking for unverified users
server/tests/auth.spec.ts Updated test endpoints to use new verify-email-code route
pages/verify-email/+onBeforeRender.ts New server-side page handler to extract token from URL
pages/verify-email/+Page.vue New UI page for token-based verification with resend functionality
pages/register/+Page.vue Renamed VerifyEmail component to VerifyEmailForm for clarity
locales/en-us.json Added localization strings for email verification page
components/registration/VerifyEmailForm.vue Updated API endpoint to use verify-email-code

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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()}`,
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect link for unverified users points back to the login endpoint, which will likely result in an infinite redirect loop. The link should probably direct users to a page where they can request a new verification email or enter their verification code, not back to the login page that will block them again.

Suggested change
'Go': `${SELF_BASE}/api/v1/auth/login?${redirectParams.toString()}`,
'Go': `${SELF_BASE}/verify-email?${redirectParams.toString()}`,

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant