Skip to content

Commit cbbee07

Browse files
committed
feat(auth): Implement email verification using SendGrid
Add email verification system for local authentication strategy: - Add SendGrid integration for sending verification emails - Require email verification before allowing login - Add verification token and expiry fields to User schema - Implement verification endpoints (/verify-email and /resend-verification) - Add rate limiting for verification email requests - Add debug email routes for development environment - Update registration flow to send verification email - Add error handling for verification-related errors BREAKING CHANGE: Local authentication now requires email verification before login
1 parent 5371fcf commit cbbee07

File tree

10 files changed

+590
-11
lines changed

10 files changed

+590
-11
lines changed

package-lock.json

Lines changed: 46 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"license": "ISC",
2626
"dependencies": {
2727
"@octokit/rest": "^20.1.1",
28+
"@sendgrid/mail": "^8.1.4",
2829
"bcrypt": "^5.1.1",
2930
"compression": "^1.7.4",
3031
"connect-mongo": "^5.1.0",

src/auth/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const {
55
getGitHubStrategy,
66
getOrCreateUserFromGitHubProfile,
77
} = require('./githubStrategy');
8-
const { localStrategy, registerUser } = require('./localStrategy');
8+
const { localStrategy, registerUser, verifyEmail, resendVerificationEmail } = require('./localStrategy');
99
const {
1010
getGoogleStrategy,
1111
getOrCreateUserFromGoogleProfile,
@@ -29,4 +29,6 @@ module.exports = {
2929
registerUser,
3030
getGoogleStrategy,
3131
getOrCreateUserFromGoogleProfile,
32+
verifyEmail,
33+
resendVerificationEmail,
3234
};

src/auth/localStrategy.js

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,38 @@
11
const LocalStrategy = require('passport-local').Strategy;
22
const bcrypt = require('bcrypt');
3+
const crypto = require('crypto');
34
const {
45
getByUsername,
56
getByEmail,
67
create,
8+
updateById,
9+
findByVerificationToken,
10+
refreshVerificationToken,
711
} = require('../domains/user/service');
812
const { AppError } = require('../libraries/error-handling/AppError');
13+
const { sendVerificationEmail } = require('../libraries/email/emailService');
914

1015
const verifyCallback = async (username, password, done) => {
1116
try {
1217
// Find user by email (since we're using email as username)
1318
const user = await getByEmail(username);
14-
19+
1520
if (!user) {
1621
return done(null, false, { message: 'Incorrect email.' });
1722
}
1823

1924
// Verify this is a local auth user
2025
if (user.authType !== 'local') {
21-
return done(null, false, {
22-
message: `Please use ${user.authType} authentication for this account.`
26+
return done(null, false, {
27+
message: `Please use ${user.authType} authentication for this account.`
2328
});
2429
}
2530

31+
// Check if email is verified
32+
if (!user.isVerified) {
33+
return done(null, false, { message: 'Please verify your email address before signing in.', reason: 'email-not-verified' });
34+
}
35+
2636
// Verify password
2737
const isValidPassword = await bcrypt.compare(password, user.local.password);
2838
if (!isValidPassword) {
@@ -40,6 +50,10 @@ const verifyCallback = async (username, password, done) => {
4050
}
4151
};
4252

53+
const generateVerificationToken = () => {
54+
return crypto.randomBytes(32).toString('hex');
55+
};
56+
4357
const registerUser = async ({ email, password }) => {
4458
try {
4559
// Check if user already exists
@@ -52,23 +66,32 @@ const registerUser = async ({ email, password }) => {
5266
const salt = await bcrypt.genSalt(10);
5367
const hashedPassword = await bcrypt.hash(password, salt);
5468

69+
// Generate verification token
70+
const verificationToken = generateVerificationToken();
71+
const verificationTokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now
72+
5573
// Create user payload matching new schema structure
5674
const payload = {
5775
email,
5876
displayName: email,
59-
authType: 'local', // Specify auth type
77+
authType: 'local',
6078
local: {
6179
username: email,
6280
password: hashedPassword,
6381
},
6482
isDemo: false,
6583
isVerified: false,
6684
isAdmin: false,
85+
verificationToken,
86+
verificationTokenExpiry,
6787
};
6888

6989
// Create the user
7090
const newUser = await create(payload);
7191

92+
// Send verification email
93+
await sendVerificationEmail(email, verificationToken);
94+
7295
// Prepare the user object for the session
7396
const userObj = newUser.toObject();
7497
const trimmedPayloadForSession = {
@@ -87,6 +110,55 @@ const registerUser = async ({ email, password }) => {
87110
}
88111
};
89112

113+
const verifyEmail = async (token) => {
114+
try {
115+
const user = await findByVerificationToken(token);
116+
117+
if (!user) {
118+
throw new AppError('invalid-token', 'Invalid or expired verification token', 400);
119+
}
120+
121+
// Update user as verified
122+
await updateById(user._id, {
123+
isVerified: true,
124+
verificationToken: null,
125+
verificationTokenExpiry: null,
126+
updatedAt: new Date()
127+
});
128+
129+
return { message: 'Email verified successfully' };
130+
} catch (error) {
131+
if (error instanceof AppError) {
132+
throw error;
133+
}
134+
throw new AppError('verification-failed', error.message, 400);
135+
}
136+
};
137+
138+
const resendVerificationEmail = async (email) => {
139+
try {
140+
const { user, verificationToken } = await refreshVerificationToken(email);
141+
142+
// Send new verification email
143+
await sendVerificationEmail(email, verificationToken);
144+
145+
return {
146+
message: 'Verification email sent successfully',
147+
email: user.email
148+
};
149+
} catch (error) {
150+
if (error instanceof AppError) {
151+
throw error;
152+
}
153+
throw new AppError('resend-verification-failed', error.message, 400);
154+
}
155+
};
156+
90157
const localStrategy = new LocalStrategy(verifyCallback);
91158

92-
module.exports = { localStrategy, registerUser };
159+
module.exports = {
160+
localStrategy,
161+
registerUser,
162+
verifyEmail,
163+
resendVerificationEmail,
164+
};

src/configs/config.schema.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const schema = Joi.object({
3333
ADMIN_USERNAMES: Joi.array().items(Joi.string()).required(),
3434
SUPERADMIN_EMAIL: Joi.string().email().required(),
3535
SUPERADMIN_PASSWORD: Joi.string().min(8).required(),
36+
// SendGrid Configuration
37+
SENDGRID_API_KEY: Joi.string().required(),
38+
SENDGRID_FROM_EMAIL: Joi.string().email().required(),
39+
SENDGRID_VERIFICATION_TEMPLATE_ID: Joi.string().required(),
3640
});
3741

3842
module.exports = schema;

src/domains/user/schema.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ const schema = new mongoose.Schema({
8888
},
8989
},
9090

91+
// Email verification fields
92+
verificationToken: {
93+
type: String,
94+
sparse: true,
95+
},
96+
verificationTokenExpiry: {
97+
type: Date,
98+
},
99+
91100
// Auth and status flags
92101
isDemo: {
93102
type: Boolean,
@@ -183,5 +192,6 @@ schema.index({ 'github.id': 1 }, { unique: true, sparse: true });
183192
schema.index({ 'google.id': 1 }, { unique: true, sparse: true });
184193
schema.index({ 'local.username': 1 }, { unique: true, sparse: true });
185194
schema.index({ email: 1 }, { unique: true });
195+
schema.index({ verificationToken: 1 }, { sparse: true });
186196

187197
module.exports = mongoose.model('User', schema);

src/domains/user/service.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const logger = require('../../libraries/log/logger');
2-
32
const Model = require('./schema');
43
const { AppError } = require('../../libraries/error-handling/AppError');
4+
const crypto = require('crypto');
55

66
const model = 'user';
77
const projection = { accessToken: 0, accessTokenIV: 0 };
@@ -203,6 +203,83 @@ const followUser = async (followerId, followedId) => {
203203
}
204204
};
205205

206+
const findByVerificationToken = async (token) => {
207+
try {
208+
return await Model.findOne({
209+
verificationToken: token,
210+
verificationTokenExpiry: { $gt: new Date() },
211+
isVerified: false
212+
});
213+
} catch (error) {
214+
logger.error('findByVerificationToken(): Failed to find user by token', error);
215+
throw new AppError('Failed to find user by token', error.message, 400);
216+
}
217+
};
218+
219+
const generateVerificationToken = () => {
220+
return crypto.randomBytes(32).toString('hex');
221+
};
222+
223+
const canResendVerification = async (userId) => {
224+
const user = await Model.findById(userId);
225+
if (!user) {
226+
throw new AppError('user-not-found', 'User not found', 404);
227+
}
228+
229+
// If user is already verified
230+
if (user.isVerified) {
231+
throw new AppError('already-verified', 'Email is already verified', 400);
232+
}
233+
234+
// Check if last token was generated less than 1 minute ago
235+
if (user.verificationTokenExpiry) {
236+
const timeSinceLastEmail = new Date() - user.verificationTokenExpiry;
237+
const oneMinuteInMs = 1 * 60 * 1000;
238+
239+
if (timeSinceLastEmail < oneMinuteInMs) {
240+
const remainingSeconds = Math.ceil((oneMinuteInMs - timeSinceLastEmail) / 1000);
241+
throw new AppError(
242+
'rate-limit',
243+
`Please wait ${remainingSeconds} seconds before requesting another verification email`,
244+
429
245+
);
246+
}
247+
}
248+
249+
return true;
250+
};
251+
252+
const refreshVerificationToken = async (email) => {
253+
try {
254+
const user = await Model.findOne({ email, authType: 'local' });
255+
256+
if (!user) {
257+
throw new AppError('user-not-found', 'No account found with this email', 404);
258+
}
259+
260+
await canResendVerification(user._id);
261+
262+
// Generate new verification token
263+
const verificationToken = generateVerificationToken();
264+
const verificationTokenExpiry = new Date(Date.now() + 1 * 60 * 1000); // 1 minute
265+
266+
// Update user with new token
267+
await updateById(user._id, {
268+
verificationToken,
269+
verificationTokenExpiry,
270+
updatedAt: new Date()
271+
});
272+
273+
return { user, verificationToken };
274+
} catch (error) {
275+
if (error instanceof AppError) {
276+
throw error;
277+
}
278+
logger.error('refreshVerificationToken(): Failed to refresh token', error);
279+
throw new AppError('refresh-token-failed', error.message, 400);
280+
}
281+
};
282+
206283
module.exports = {
207284
create,
208285
search,
@@ -217,4 +294,6 @@ module.exports = {
217294
activateUser,
218295
getByEmail,
219296
getByGoogleId,
297+
findByVerificationToken,
298+
refreshVerificationToken,
220299
};

0 commit comments

Comments
 (0)