diff --git a/.env.example b/.env.example index 6acc9b8..30418bf 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ NODE_ENV= PINO_LOG_LEVEL= JWT_ACCESS_SECRET= JWT_REFRESH_SECRET= +JWT_TOKEN_FOR_ACTION_SECRET= MASTER_OTP= INITIAL_SETUP_DONE= diff --git a/TODO.md b/TODO.md index ce5fdbc..e2a73de 100644 --- a/TODO.md +++ b/TODO.md @@ -38,3 +38,18 @@ - Define the default permissions for `admin` since super-admin have all the permission and we can't give all permission to admin. (e.g. super-admin is like a president and admin is like a PM) - Find a mechanism to allow user to switch their role. (Low priority). + +## Forget Password Schema + +- Handle Rate Limiting for Token Generation and Use +- Handle Token Expiry and Auto-cleanup in Token Schema + +## Secure OTP + +- Add OTP Lock and Attempts Fields to User Schema +- Reset OTP Attempts After Successful Verification +- Log Failed OTP Attempts for Security Monitoring +- Temporarily Lock User After Exceeding OTP Attempts +- Unlock User Account Manually via Admin Control +- Handle OTP Abuse by Tracking Failed Attempts +- Implement OTP Cool down to Limit Requests per User diff --git a/package.json b/package.json index 91c624b..4cd6732 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "axios": "^1.7.4", "bcrypt": "^5.1.1", "cors": "^2.8.5", + "date-fns": "^3.6.0", "dotenv": "^16.4.5", "express": "^4.19.2", "helmet": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6f5d0d..09b74e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 + date-fns: + specifier: ^3.6.0 + version: 3.6.0 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -974,6 +977,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3784,6 +3790,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + date-fns@3.6.0: {} + debug@2.6.9: dependencies: ms: 2.0.0 diff --git a/src/api/v1/auth/__test__/forget-password.test.ts b/src/api/v1/auth/__test__/forget-password.test.ts new file mode 100644 index 0000000..ca64075 --- /dev/null +++ b/src/api/v1/auth/__test__/forget-password.test.ts @@ -0,0 +1,29 @@ +import { + expectBadRequestResponseForValidationError, + expectForgetPasswordSuccess, + expectUserNotFoundError, + forgetPassword, +} from '@/utils/test'; +import { defaultUsers } from '@/constants'; + +describe('Forget Password', () => { + it('should throw error if email is not provided', async () => { + const res = await forgetPassword(''); + expectBadRequestResponseForValidationError(res); + }); + + it('should throw error if email is invalid', async () => { + const res = await forgetPassword('a'); + expectBadRequestResponseForValidationError(res); + }); + + it('should throw error if email is not registered', async () => { + const res = await forgetPassword('a@a.com'); + expectUserNotFoundError(res); + }); + + it('should send forget password email', async () => { + const res = await forgetPassword(defaultUsers.email); + expectForgetPasswordSuccess(res); + }); +}); diff --git a/src/api/v1/auth/__test__/reset-password.test.ts b/src/api/v1/auth/__test__/reset-password.test.ts new file mode 100644 index 0000000..820bda7 --- /dev/null +++ b/src/api/v1/auth/__test__/reset-password.test.ts @@ -0,0 +1,87 @@ +import { + expectBadRequestResponseForValidationError, + expectFindUserByUsernameSuccess, + expectForgetPasswordSuccess, + expectLoginFailed, + expectLoginSuccess, + expectOTPVerificationSuccess, + expectResetPasswordSuccess, + expectSignUpSuccess, + findUserByUsername, + forgetPassword, + login, + resetPassword, + retrieveOTP, + signUp, + verifyAccount, + verifyOTP, +} from '@/utils/test'; +import { Response } from 'supertest'; +import { GetUser } from '../../user/user.validation'; + +const user = { + username: 'username', + email: 'validemail@example.com', + password: 'ValidPassword123!', + confirmPassword: 'ValidPassword123!', +}; + +describe('Reset Password', () => { + beforeAll(async () => { + const response = await signUp(user); + expectSignUpSuccess(response); + + verifyAccount(user); + }); + + it('should throw error if token, password, confirmPassword is not provided', async () => { + const res = await resetPassword({ token: '', password: '', confirmPassword: '' }); + expectBadRequestResponseForValidationError(res); + }); + + it('should throw error if token is invalid', async () => { + const res = await resetPassword({ token: 'a', password: 'a', confirmPassword: 'a' }); + expectBadRequestResponseForValidationError(res); + }); + + it('should throw error if password and confirmPassword do not match', async () => { + const res = await resetPassword({ token: 'a', password: 'a', confirmPassword: 'b' }); + expectBadRequestResponseForValidationError(res); + }); + + // Running these test twice to ensure that after user can successfully reset password + // should reset password again after isUsed flag is reset + for (let i = 0; i < 2; i++) { + let verifiedOTPResponse: Response; + + it('should verify OTP successfully', async () => { + const res = await forgetPassword(user.email); + expectForgetPasswordSuccess(res); + + const userResponse = await findUserByUsername(user.username); + expectFindUserByUsernameSuccess(userResponse, user); + const userDetails: GetUser = userResponse.body.user; + + const otpData = await retrieveOTP(userDetails.id, 'sendForgetPasswordOTP'); + verifiedOTPResponse = await verifyOTP(otpData, user.email); + expectOTPVerificationSuccess(verifiedOTPResponse); + }); + + it('should reset password successfully', async () => { + const { token } = verifiedOTPResponse.body; + + const res = await resetPassword({ token, password: 'ValidPassword123@', confirmPassword: 'ValidPassword123@' }); + expectResetPasswordSuccess(res); + }); + + it('should not login with old password', async () => { + const res = await login(user); + expectLoginFailed(res); + }); + + it('should login with new password', async () => { + const res = await login({ ...user, password: 'ValidPassword123@' }); + expectLoginSuccess(res); + }); + } +}); diff --git a/src/api/v1/auth/auth.constant.ts b/src/api/v1/auth/auth.constant.ts index f3f4b50..d4e44eb 100644 --- a/src/api/v1/auth/auth.constant.ts +++ b/src/api/v1/auth/auth.constant.ts @@ -7,4 +7,6 @@ export const success = { OTP_SENT: 'OTP sent', OTP_VERIFIED: 'OTP verified', TOKEN_VERIFIED: 'Access token is verified', + FORGET_PASSWORD_EMAIL_SENT: 'Forget password email sent', + PASSWORD_RESET_SUCCESSFULLY: 'Password reset successfully', }; diff --git a/src/api/v1/auth/auth.controller.ts b/src/api/v1/auth/auth.controller.ts index 72763f9..4e757da 100644 --- a/src/api/v1/auth/auth.controller.ts +++ b/src/api/v1/auth/auth.controller.ts @@ -53,6 +53,8 @@ export class AuthController { const { accessToken, refreshToken } = await AuthService.signInWithGoogle(googleUser); AuthController.setTokenCookies(res, accessToken, refreshToken); + + // TODO: used action token instead of access token res.cookie('access-token', accessToken, { httpOnly: false, secure: true, @@ -108,4 +110,30 @@ export class AuthController { next(error); } } + + public static async forgetPassword(req: Request, res: Response, next: NextFunction) { + try { + await AuthService.forgetPassword(req.body.email); + sendResponse({ + response: res, + message: success.FORGET_PASSWORD_EMAIL_SENT, + statusCode: STATUS_CODES.OK, + }); + } catch (error) { + next(error); + } + } + + public static async resetPassword(req: Request, res: Response, next: NextFunction) { + try { + await AuthService.resetPassword(req.body); + sendResponse({ + response: res, + message: success.PASSWORD_RESET_SUCCESSFULLY, + statusCode: STATUS_CODES.OK, + }); + } catch (error) { + next(error); + } + } } diff --git a/src/api/v1/auth/auth.route.ts b/src/api/v1/auth/auth.route.ts index 905def5..8554520 100644 --- a/src/api/v1/auth/auth.route.ts +++ b/src/api/v1/auth/auth.route.ts @@ -9,12 +9,10 @@ router.post('/auth/login', AuthController.signInWithEmailOrUsernameAndPassword); router.post('/auth/logout', validateAccessToken, AuthController.logout); router.post('/auth/renew-token', validateRefreshToken, AuthController.renewToken); router.post('/auth/verify-token', validateAccessToken, AuthController.verifyToken); +router.post('/auth/forget-password', AuthController.forgetPassword); +router.post('/auth/reset-password', AuthController.resetPassword); router.get('/auth/google', signInWithGoogle); router.get('/auth/google/callback', signInWithGoogleCallback, AuthController.signInWithGoogleCallback); -// router.post('/auth/send-reset-password-email', AuthController.sendResetPasswordEmail); -// router.post('/auth/reset-password', AuthController.resetPassword); -// router.post('/auth/verify-email', AuthController.verifyEmail); - export { router as authRoutes }; diff --git a/src/api/v1/auth/auth.service.ts b/src/api/v1/auth/auth.service.ts index 14e6acf..861c88f 100644 --- a/src/api/v1/auth/auth.service.ts +++ b/src/api/v1/auth/auth.service.ts @@ -2,11 +2,19 @@ import { ErrorTypeEnum } from '@/constants'; import { comparePassword, generateAccessToken, generateRefreshToken, validateObjectId } from '@/utils'; import { UserDAL } from '@/api/v1/user/user.dal'; import { UserService } from '@/api/v1/user/user.service'; -import { CreateUser } from '@/api/v1/user/user.validation'; +import { + CreateUser, + Email, + ResetPassword, + validateEmail, + validateResetPasswordSchema, +} from '@/api/v1/user/user.validation'; import { AuthDAL } from './auth.dal'; import { loginSchema, Login, AuthToken, Auth } from './auth.validation'; import { OtpService } from '@/api/v1/otp/otp.service'; import { GoogleUser } from '@/types/passport-google'; +import { TokenAction, TokenService } from '@/api/v1/token'; +import { notificationService } from '@/services'; export class AuthService { public static async signUp(userData: CreateUser) { @@ -81,6 +89,24 @@ export class AuthService { return user; } + public static async forgetPassword(email: Email) { + validateEmail(email); + await OtpService.sendOtp({ email, otpType: 'sendForgetPasswordOTP' }); + } + + public static async resetPassword(resetPassword: ResetPassword) { + const { password, token } = validateResetPasswordSchema(resetPassword); + + const { userId } = await new TokenService().verifyActionToken(token, TokenAction.resetPassword); + + const user = await UserService.updateUser(userId, { password }); + + notificationService.sendEmail({ + to: user.email, + eventType: 'sendPasswordChangeConfirmation', + }); + } + static async generateAccessAndRefreshToken(userId: string) { validateObjectId(userId); diff --git a/src/api/v1/auth/auth.validation.ts b/src/api/v1/auth/auth.validation.ts index 317bac6..49bedfc 100644 --- a/src/api/v1/auth/auth.validation.ts +++ b/src/api/v1/auth/auth.validation.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { userSchema } from '../user/user.validation'; import { otp } from '../otp/otp.validation'; +import { tokenSchema } from '../token/token.validation'; const AuthSchema = z.object({ userId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid id'), @@ -21,6 +22,11 @@ export const jwtRefreshTokenSchema = z.object({ id: z.string(), }); +export const jwtActionTokenSchema = z.object({ + userId: z.string(), + action: tokenSchema.shape.action, +}); + export const sendOtpSchema = z.object({ payload: userSchema.shape.email, eventType: z.enum(['signUp', 'resetPassword', 'sendEmailVerificationOTP']), @@ -53,6 +59,7 @@ export const eventTypes = z.enum(['sendEmailVerificationOTP', 'verifyPhoneNumber export type JwtAccessToken = z.infer; export type JwtRefreshToken = z.infer; +export type JwtActionToken = z.infer; export type Login = z.infer; export type Auth = z.infer; export type AuthToken = z.infer; diff --git a/src/api/v1/otp/otp.controller.ts b/src/api/v1/otp/otp.controller.ts index d4f977c..e1348d4 100644 --- a/src/api/v1/otp/otp.controller.ts +++ b/src/api/v1/otp/otp.controller.ts @@ -20,12 +20,22 @@ export class OtpController { public static async verifyOtp(req: Request, res: Response, next: NextFunction) { try { - const { message } = await OtpService.verifyOtp(req.body); + const { message, token } = await OtpService.verifyOtp(req.body); + + if (token != null) { + res.cookie('action-token', token, { + httpOnly: false, + secure: true, + sameSite: 'strict', + maxAge: 60000, // 1 minute + }); + } sendResponse({ response: res, message: message, statusCode: STATUS_CODES.OK, + payload: { token }, }); } catch (error) { next(error); diff --git a/src/api/v1/otp/otp.dal.ts b/src/api/v1/otp/otp.dal.ts index e54bbe6..7fd16f1 100644 --- a/src/api/v1/otp/otp.dal.ts +++ b/src/api/v1/otp/otp.dal.ts @@ -7,8 +7,8 @@ export class OtpDAL { return await newOtp.save(); } - static async getOtp(otpSchema: GetOtp): Promise { - return await OtpModel.findOne(otpSchema); + static async getOtpDetailsByUserId(otpSchema: GetOtp): Promise { + return await OtpModel.find(otpSchema); } static async deleteOtp(otpId: string) { diff --git a/src/api/v1/otp/otp.service.ts b/src/api/v1/otp/otp.service.ts index 839bbf8..cbe6a90 100644 --- a/src/api/v1/otp/otp.service.ts +++ b/src/api/v1/otp/otp.service.ts @@ -1,10 +1,14 @@ +import { isPast } from 'date-fns'; + import { envConstants, ErrorTypeEnum, FIVE_MINUTES_IN_MS } from '@/constants'; import { generateOTP, verifyOTP } from '@/utils'; import { success } from '@/api/v1/auth/auth.constant'; import { UserService } from '@/api/v1/user/user.service'; import { OtpDAL } from './otp.dal'; -import { OtpEvent, otpEvent, OtpVerifyEvent, otpVerifyEvent } from './otp.validation'; +import { GetOtp, OtpEvent, otpEvent, OtpSchema, OtpVerifyEvent, otpVerifyEvent } from './otp.validation'; import { notificationService } from '@/services/notification.services'; +import { TokenService } from '../token/token.service'; +import { TokenAction } from '../token/token.validation'; export class OtpService { public static async sendOtp(otpEvents: OtpEvent): Promise<{ message: string }> { @@ -22,7 +26,20 @@ export class OtpService { notificationService.sendEmail({ to: user.email, - eventType: 'sendEmailVerificationOTP', + eventType: otpType, + payload: { otp }, + }); + } + + if (otpType === 'sendForgetPasswordOTP') { + // TODO: If user try to forget password then logout him from all devices because of security reasons + // because after verifying OTP he will get a token (access token) that will allow him to access all the resources with that token that we sent to him as a response. + // + message = success.FORGET_PASSWORD_EMAIL_SENT; + + notificationService.sendEmail({ + to: user.email, + eventType: otpType, payload: { otp }, }); } @@ -37,32 +54,46 @@ export class OtpService { return { message }; } - public static async verifyOtp(otpEvents: OtpVerifyEvent): Promise<{ message: string }> { + public static async verifyOtp(otpEvents: OtpVerifyEvent): Promise<{ message: string; token?: string }> { const { otp, email, otpType } = otpVerifyEvent.parse(otpEvents); + let token; + const isMasterOTP = envConstants.MASTER_OTP === otp; const user = await UserService.getUserByEmail(email as string); - const isMasterOTP = envConstants.MASTER_OTP === otp; - - const otpData = await OtpDAL.getOtp({ + const otpDetails = await OtpService.getOtpDetailsByUserId({ userId: user.id, otpType, }); + const otpData = otpDetails.find((data) => data.otpType === otpType); + if (!otpData) throw new Error(ErrorTypeEnum.enum.OTP_NOT_REQUESTED); const otpToVerify = isMasterOTP && envConstants.NODE_ENV !== 'production' ? envConstants.MASTER_OTP : otpData.otp; if (!verifyOTP(otpToVerify, otp as string)) throw new Error(ErrorTypeEnum.enum.INVALID_OTP); - if (otpData.expiryTime < new Date()) throw new Error(ErrorTypeEnum.enum.OTP_EXPIRED); + if (isPast(otpData.expiryTime)) throw new Error(ErrorTypeEnum.enum.OTP_EXPIRED); if (otpType === 'sendEmailVerificationOTP') { await UserService.updateUser(user.id, { isEmailVerified: true }); } + if (otpType === 'sendForgetPasswordOTP') { + token = await new TokenService().createActionToken(user.id, TokenAction.resetPassword); + } + await OtpDAL.deleteOtp(otpData.id); - return { message: success.OTP_VERIFIED }; + return { message: success.OTP_VERIFIED, token }; + } + + public static async getOtpDetailsByUserId(getOtpDetails: GetOtp): Promise { + const otpDetails = await OtpDAL.getOtpDetailsByUserId(getOtpDetails); + + if (!otpDetails) throw new Error(ErrorTypeEnum.enum.OTP_NOT_REQUESTED); + + return otpDetails; } } diff --git a/src/api/v1/otp/otp.validation.ts b/src/api/v1/otp/otp.validation.ts index 46d3f7f..0b03e6d 100644 --- a/src/api/v1/otp/otp.validation.ts +++ b/src/api/v1/otp/otp.validation.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { userSchema, validateEmail } from '@/api/v1/user/user.validation'; -const otpType = z.enum(['sendEmailVerificationOTP', 'verifyPhoneNumber', 'resetPassword', 'forgetPassword']); +const otpType = z.enum(['sendEmailVerificationOTP', 'verifyPhoneNumber', 'sendForgetPasswordOTP']); export const otp = z.string().length(6); const baseSchema = z.object({ @@ -13,9 +13,9 @@ const baseSchema = z.object({ type BaseSchema = z.infer; const validateOtpEvent = (data: BaseSchema) => { - if (['sendEmailVerificationOTP', 'resetPassword', 'forgetPassword'].includes(data.otpType)) { + if (['sendEmailVerificationOTP', 'sendForgetPasswordOTP'].includes(data.otpType)) { if (data.email !== undefined) { - return validateEmail.parse(data.email); + return validateEmail(data.email); } else { const error = new z.ZodError([]); error.addIssue({ diff --git a/src/api/v1/token/index.ts b/src/api/v1/token/index.ts new file mode 100644 index 0000000..75d61e7 --- /dev/null +++ b/src/api/v1/token/index.ts @@ -0,0 +1,2 @@ +export * from './token.service'; +export * from './token.validation'; diff --git a/src/api/v1/token/token.dal.ts b/src/api/v1/token/token.dal.ts new file mode 100644 index 0000000..1422ec3 --- /dev/null +++ b/src/api/v1/token/token.dal.ts @@ -0,0 +1,43 @@ +import { TokenModel } from './token.model'; +import { CreateToken, Token, UpdateToken } from './token.validation'; + +interface ITokenDal { + saveToken(payload: Token): Promise; + getToken(token: string): Promise; + getTokensByUserId(userId: string): Promise; + updateToken(tokenId: string, payload: Token): Promise; + deleteToken(tokenId: string): Promise; + replaceTokenForUser(createTokenSchema: CreateToken): Promise; +} + +export class TokenDAL implements ITokenDal { + async saveToken(payload: CreateToken): Promise { + return await TokenModel.create(payload); + } + + async getToken(token: string): Promise { + return await TokenModel.findOne({ token }); + } + + async getTokensByUserId(userId: string): Promise { + return await TokenModel.find({ userId }); + } + + async updateToken(tokenId: string, payload: UpdateToken): Promise { + return await TokenModel.findByIdAndUpdate(tokenId, payload, { new: true }); + } + + async deleteToken(tokenId: string): Promise { + return await TokenModel.findByIdAndDelete(tokenId); + } + + async replaceTokenForUser(tokenSchema: CreateToken): Promise { + return await TokenModel.findOneAndUpdate( + { userId: tokenSchema.userId, action: tokenSchema.action }, + { ...tokenSchema, $inc: { requestAttempts: 1 } }, + { upsert: true, new: true }, + ); + } +} + +export default TokenDAL; diff --git a/src/api/v1/token/token.model.ts b/src/api/v1/token/token.model.ts new file mode 100644 index 0000000..7641ec7 --- /dev/null +++ b/src/api/v1/token/token.model.ts @@ -0,0 +1,43 @@ +import { model, Model, Schema, Types } from 'mongoose'; +import { Token, TokenAction } from './token.validation'; + +const TokenSchema: Schema = new Schema( + { + userId: { + type: String, + required: true, + validate: { + validator: (v: string) => Types.ObjectId.isValid(v), + message: 'userId must be a valid MongoDB ObjectId string', + }, + ref: 'User', + }, + action: { + type: String, + enum: Object.values(TokenAction), + required: true, + }, + token: { + type: String, + required: true, + }, + expiryTime: { + type: Date, + required: true, + }, + isUsed: { + type: Boolean, + default: false, + }, + requestAttempts: { + type: Number, + default: 0, + }, + metadata: { + type: Object, + }, + }, + { timestamps: true }, +); + +export const TokenModel: Model = model('Token', TokenSchema); diff --git a/src/api/v1/token/token.service.ts b/src/api/v1/token/token.service.ts new file mode 100644 index 0000000..418ac94 --- /dev/null +++ b/src/api/v1/token/token.service.ts @@ -0,0 +1,52 @@ +import { generateTokenForAction, verifyJWTToken } from '@/utils'; +import { TokenDAL } from './token.dal'; +import { CreateToken, Token, TokenAction } from './token.validation'; +import { ErrorTypeEnum, FIVE_MINUTES_IN_MS } from '@/constants'; +import { isPast } from 'date-fns'; + +export class TokenService { + private tokenDAL: TokenDAL; + + constructor() { + this.tokenDAL = new TokenDAL(); + } + + async createActionToken(userId: string, action: TokenAction) { + const token = generateTokenForAction({ userId, action }); + + const tokenPayload: CreateToken = { + userId, + action, + token, + isUsed: false, + expiryTime: new Date(Date.now() + FIVE_MINUTES_IN_MS), + }; + + await this.tokenDAL.replaceTokenForUser(tokenPayload); + return token; + } + + async verifyActionToken(token: string, action: TokenAction): Promise { + const verifiedToken = await verifyJWTToken(token, 'action'); + + // Check if the token is valid for the intended action + // e.g. if the token is for changing subscription, then the action must be changeSubscription + if (verifiedToken.action !== action) { + throw new Error(ErrorTypeEnum.enum.INVALID_TOKEN_TYPE); + } + + const storedToken = await this.tokenDAL.getToken(token); + + if (!storedToken) { + throw new Error(ErrorTypeEnum.enum.INVALID_ACCESS); + } + + if (storedToken.isUsed || isPast(storedToken.expiryTime)) { + throw new Error(ErrorTypeEnum.enum.TOKEN_EXPIRED); + } + + await this.tokenDAL.updateToken(storedToken.id, { isUsed: true }); + + return verifiedToken; + } +} diff --git a/src/api/v1/token/token.validation.ts b/src/api/v1/token/token.validation.ts new file mode 100644 index 0000000..a3c2b7f --- /dev/null +++ b/src/api/v1/token/token.validation.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { objectIdSchema } from '@/utils'; + +export enum TokenAction { + 'resetPassword' = 'resetPassword', + 'deleteAccount' = 'deleteAccount', + 'changeSubscription' = 'changeSubscription', +} + +export const tokenSchema = z.object({ + id: objectIdSchema, + userId: objectIdSchema, + action: z.nativeEnum(TokenAction), + token: z.string(), + isUsed: z.boolean(), + expiryTime: z.date(), + requestAttempts: z.number(), + metadata: z.any().optional(), +}); + +export const createTokenSchema = tokenSchema.pick({ + userId: true, + action: true, + token: true, + expiryTime: true, + isUsed: true, +}); + +export const updateTokenSchema = tokenSchema + .pick({ isUsed: true, requestAttempts: true }) + .partial() + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field is required for token update', + }); + +export type Token = z.infer; +export type CreateToken = z.infer; +export type GetToken = z.infer; +export type UpdateToken = z.infer; +export type DeleteToken = z.infer; +export type GetTokens = z.infer; diff --git a/src/api/v1/user/user.dal.ts b/src/api/v1/user/user.dal.ts index 749c0f2..bfbe5f3 100644 --- a/src/api/v1/user/user.dal.ts +++ b/src/api/v1/user/user.dal.ts @@ -2,6 +2,7 @@ import mongoose from 'mongoose'; import { UserModel } from './user.model'; import { CreateUser, UpdateUser, User, UserRolePermission } from './user.validation'; import { Login } from '../auth/auth.validation'; +import { hashPassword } from '../../../utils'; export class UserDAL { static async createUser(userData: CreateUser): Promise { @@ -51,6 +52,9 @@ export class UserDAL { } static async updateUser(userId: string, userData: UpdateUser): Promise { + if (userData.password !== null && userData.password !== undefined) { + userData.password = await hashPassword(userData.password); + } return await UserModel.findByIdAndUpdate(userId, userData, { new: true }); } diff --git a/src/api/v1/user/user.service.ts b/src/api/v1/user/user.service.ts index 0abe93a..e4b61b0 100644 --- a/src/api/v1/user/user.service.ts +++ b/src/api/v1/user/user.service.ts @@ -81,9 +81,9 @@ export class UserService { } static async getUserByEmail(email: Email): Promise { - const validEmail = validateEmail.parse(email); + validateEmail(email); - const user = await UserDAL.getUserByEmail(validEmail); + const user = await UserDAL.getUserByEmail(email); if (!user) throw new Error(ErrorTypeEnum.enum.USER_NOT_FOUND); @@ -111,13 +111,9 @@ export class UserService { } static async updateUser(userId: string, userData: UpdateUser): Promise { - const validateData = validateObjectId(userId); - - const user = await UserDAL.getUserById(validateData); - - if (!user) throw new Error(ErrorTypeEnum.enum.USER_NOT_FOUND); + validateObjectId(userId); - const updatedUser = await UserDAL.updateUser(validateData, userData); + const updatedUser = await UserDAL.updateUser(userId, userData); if (!updatedUser) throw new Error(ErrorTypeEnum.enum.INTERNAL_SERVER_ERROR); diff --git a/src/api/v1/user/user.validation.ts b/src/api/v1/user/user.validation.ts index 723037f..9dd375c 100644 --- a/src/api/v1/user/user.validation.ts +++ b/src/api/v1/user/user.validation.ts @@ -64,11 +64,17 @@ const createUserWithEmailAndPasswordSchema = userSchema const createUserSchema = z.union([createUserWithEmailAndPasswordSchema, createUserWithGoogleSchema]); export const validateUsername = userSchema.pick({ username: true }); -export const validateEmail = z - .string() - .email() + +export const validEmail = z + .string({ message: 'Email is required' }) + .min(1, { message: 'Email is required' }) + .email({ message: 'Invalid email format' }) .transform((val) => val.toLowerCase().trim()); +export const validateEmail = (email: string): string => { + return validEmail.parse(email); +}; + export const updateUserSchema = userSchema .partial() // Make all keys optional .refine((data) => { @@ -81,17 +87,31 @@ export const updateUserSchema = userSchema }); export const getUserSchema = userSchema.omit({ password: true, confirmPassword: true, isDeleted: true }); +export const resetPasswordSchema = userSchema + .pick({ password: true, confirmPassword: true }) + .extend({ + token: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Confirm password does not match password', + path: ['confirmPassword'], + }); export type User = z.infer; export type CreateUser = z.infer; export type UpdateUser = z.infer; export type GetUser = z.infer; -export type Email = z.infer; +export type Email = z.infer; +export type ResetPassword = z.infer; export const validateCreateUser = (data: CreateUser) => { createUserSchema.parse(data); }; +export const validateResetPasswordSchema = (data: ResetPassword): { password: string; token: string } => { + return resetPasswordSchema.parse(data); +}; + export interface UserRolePermission { _id: string; username: string; diff --git a/src/constants/env.constant.ts b/src/constants/env.constant.ts index dece5e5..803ccfc 100644 --- a/src/constants/env.constant.ts +++ b/src/constants/env.constant.ts @@ -11,6 +11,7 @@ const envSchema = z.object({ NODE_ENV: z.string().min(1, 'NODE_ENV is required').readonly(), JWT_ACCESS_SECRET: z.string().min(1, 'JWT_ACCESS_SECRET is required').readonly(), JWT_REFRESH_SECRET: z.string().min(1, 'JWT_REFRESH_SECRET is required').readonly(), + JWT_TOKEN_FOR_ACTION_SECRET: z.string().min(1, 'JWT_TOKEN_FOR_ACTION_SECRET is required').readonly(), MASTER_OTP: z.string().min(1, 'MASTER_OTP is required').readonly(), INITIAL_SETUP_DONE: z .string() diff --git a/src/constants/errorTypes.constant.ts b/src/constants/errorTypes.constant.ts index 607d5c7..d54d357 100644 --- a/src/constants/errorTypes.constant.ts +++ b/src/constants/errorTypes.constant.ts @@ -22,6 +22,7 @@ const errorType = [ 'INVALID_ACCESS', 'TOKEN_EXPIRED', 'INVALID_TOKEN', + 'INVALID_TOKEN_TYPE', 'ORIGIN_IS_UNDEFINED', 'ORIGIN_NOT_ALLOWED', 'EMAIL_ALREADY_VERIFIED', @@ -31,6 +32,7 @@ const errorType = [ 'OTP_EXPIRED', 'INITIAL_SETUP_FAILED', 'NOT_ENOUGH_PERMISSION', + 'TOO_MANY_REQUESTS', ] as const; export const ErrorTypeEnum = z.enum(errorType); @@ -116,6 +118,10 @@ export const errorMap = { httpStatusCode: STATUS_CODES.UNAUTHORIZED, body: { code: 'invalid_token', message: 'Invalid token' }, }, + [ErrorTypeEnum.enum.INVALID_TOKEN_TYPE]: { + httpStatusCode: STATUS_CODES.UNAUTHORIZED, + body: { code: 'invalid_token_type', message: 'Invalid token type' }, + }, [ErrorTypeEnum.enum.ORIGIN_IS_UNDEFINED]: { httpStatusCode: STATUS_CODES.BAD_REQUEST, body: { code: 'origin_is_undefined', message: 'Origin is undefined' }, @@ -166,6 +172,10 @@ export const errorMap = { message: 'User does not have enough permission to perform this action', }, }, + [ErrorTypeEnum.enum.TOO_MANY_REQUESTS]: { + httpStatusCode: STATUS_CODES.TOO_MANY_REQUESTS, + body: { code: 'too_many_requests', message: 'Too many requests' }, + }, }; export type ErrorTypeEnum = z.infer; diff --git a/src/constants/statusCode.constant.ts b/src/constants/statusCode.constant.ts index 45faba9..64b27a2 100644 --- a/src/constants/statusCode.constant.ts +++ b/src/constants/statusCode.constant.ts @@ -10,6 +10,7 @@ export enum STATUS_CODES { METHOD_NOT_ALLOWED = 405, CONFLICT = 409, UNPROCESSABLE_ENTITY = 422, + TOO_MANY_REQUESTS = 429, INTERNAL_SERVER_ERROR = 500, BAD_GATEWAY = 502, SERVICE_UNAVAILABLE = 503, diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 1ca00de..828961d 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -1,5 +1,5 @@ import { NextFunction, Response, Request } from 'express'; -import { extractTokenFromBearerString, verifyToken } from '@/utils/jwt.util'; +import { extractTokenFromBearerString, verifyJWTToken } from '@/utils/jwt.util'; import { ErrorTypeEnum } from '@/constants'; import { Auth, JwtAccessToken, JwtRefreshToken } from '@/api/v1/auth/auth.validation'; import { AuthService } from '@/api/v1/auth/auth.service'; @@ -13,11 +13,12 @@ const parseUser = async (req: Request, _: Response, next: NextFunction, tokenTyp const token = extractTokenFromBearerString(authHeader); let user: Auth; + // TODO: instead of first verifying jwt token and then calling auth service to get user, call auth service directly and add logic to verify jwt token in auth service if (tokenType === 'refresh') { - const { id }: JwtRefreshToken = await verifyToken(token, tokenType); + const { id }: JwtRefreshToken = await verifyJWTToken(token, tokenType); user = await AuthService.verifyToken(id); } else { - const { userId }: JwtAccessToken = await verifyToken(token, tokenType); + const { userId }: JwtAccessToken = await verifyJWTToken(token, tokenType); user = await AuthService.verifyToken(userId); } diff --git a/src/services/notification.validation.ts b/src/services/notification.validation.ts index d43ef6f..c033596 100644 --- a/src/services/notification.validation.ts +++ b/src/services/notification.validation.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; // 'sendEmailVerificationOTP', // 'sendEmailVerificationMagicLink', // 'sendEmailChangeConfirmation', -// 'sendPasswordResetOTP', +// 'sendForgetPasswordOTP', // 'sendAccountActivationEmail', // 'sendWelcomeEmail', // 'sendTwoFactorAuthCode', @@ -34,14 +34,6 @@ const emailVerificationOTPPayload = z.object({ otp: otpValidator, }); -const emailVerificationMagicLinkPayload = z.object({ - magicLink: z.string().url(), -}); - -const emailChangeConfirmationPayload = z.object({ - newEmail: emailValidator, -}); - const passwordResetOTPPayload = z.object({ otp: otpValidator, }); @@ -56,21 +48,14 @@ const emailNotification = z.discriminatedUnion('eventType', [ }), z.object({ to: emailValidator, - eventType: z.literal('sendEmailVerificationMagicLink'), - subject: z.string().optional(), - payload: emailVerificationMagicLinkPayload, - }), - z.object({ - to: emailValidator, - eventType: z.literal('sendEmailChangeConfirmation'), + eventType: z.literal('sendForgetPasswordOTP'), subject: z.string().optional(), - payload: emailChangeConfirmationPayload, + payload: passwordResetOTPPayload, }), z.object({ to: emailValidator, - eventType: z.literal('sendPasswordResetOTP'), + eventType: z.literal('sendPasswordChangeConfirmation'), subject: z.string().optional(), - payload: passwordResetOTPPayload, }), // ... Add other event types and their corresponding payloads ... ]); diff --git a/src/utils/jwt.util.ts b/src/utils/jwt.util.ts index 5465456..483f859 100644 --- a/src/utils/jwt.util.ts +++ b/src/utils/jwt.util.ts @@ -3,12 +3,20 @@ import { envConstants, ErrorTypeEnum } from '@/constants'; import { JwtAccessToken, jwtAccessTokenSchema, + JwtActionToken, + jwtActionTokenSchema, JwtRefreshToken, jwtRefreshTokenSchema, } from '@/api/v1/auth/auth.validation'; import { AuthDAL } from '@/api/v1/auth/auth.dal'; -const { JWT_ACCESS_SECRET, JWT_REFRESH_SECRET } = envConstants; +const { JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_TOKEN_FOR_ACTION_SECRET } = envConstants; + +export const tokenSecrets = { + access: JWT_ACCESS_SECRET, + refresh: JWT_REFRESH_SECRET, + action: JWT_TOKEN_FOR_ACTION_SECRET, +}; export const generateAccessToken = (payload: JwtAccessToken) => { const validPayload = jwtAccessTokenSchema.parse(payload); @@ -20,10 +28,24 @@ export const generateRefreshToken = (payload: JwtRefreshToken) => { return jwt.sign(validPayload, JWT_REFRESH_SECRET, { expiresIn: '7d' }); }; -export const verifyToken = async (token: string, tokenType: 'access' | 'refresh'): Promise => { +export const generateTokenForAction = (payload: JwtActionToken) => { + const validPayload = jwtActionTokenSchema.parse(payload); + return jwt.sign(validPayload, JWT_TOKEN_FOR_ACTION_SECRET, { expiresIn: '5m' }); +}; + +export const verifyJWTToken = async (token: string, tokenType: 'access' | 'refresh' | 'action'): Promise => { try { - const verifiedToken = jwt.verify(token, tokenType === 'access' ? JWT_ACCESS_SECRET : JWT_REFRESH_SECRET); + const secret = tokenSecrets[tokenType]; + + if (!secret) { + throw new Error(ErrorTypeEnum.enum.INTERNAL_SERVER_ERROR); + } + + const verifiedToken = jwt.verify(token, secret); + // TODO: move fetch token details to AuthService + // Security: Invalidate the refresh token after it's used to obtain a new access token, + // to prevent token reuse attacks and ensure that only the intended user can access the system. if (tokenType === 'refresh') { const storedToken = await AuthDAL.getAuthByRefreshToken(token); if (!storedToken) { diff --git a/src/utils/test/auth.utils.ts b/src/utils/test/auth.utils.ts index 8b41c09..0a52fe8 100644 --- a/src/utils/test/auth.utils.ts +++ b/src/utils/test/auth.utils.ts @@ -1,11 +1,11 @@ import supertest, { Response } from 'supertest'; import { app } from '@/app'; import { Login } from '@/api/v1/auth/auth.validation'; -import { STATUS_CODES } from '@/constants'; +import { errorMap, ErrorTypeEnum, STATUS_CODES } from '@/constants'; import { success } from '@/api/v1/auth/auth.constant'; import { expectOTPRequestSuccess, expectOTPVerificationSuccess, requestOTP, retrieveOTP, verifyOTP } from './otp.utils'; import { expectFindUserByUsernameSuccess, findUserByUsername } from './user.utils'; -import { CreateUser } from '../../api/v1/user/user.validation'; +import { CreateUser, ResetPassword } from '../../api/v1/user/user.validation'; export async function login(loginData: Login): Promise { return supertest(app).post('/api/v1/auth/login').send(loginData); @@ -29,6 +29,14 @@ export function expectLoginSuccess(response: Response): void { }); } +export const expectLoginFailed = (response: Response) => { + const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_CREDENTIALS]; + + expect(response.statusCode).toBe(errorObject.httpStatusCode); + expect(response.body.message).toBe(errorObject.body.message); + expect(response.body.code).toBe(errorObject.body.code); +}; + export async function signUp(signUpData: { email: string; username: string; @@ -89,10 +97,33 @@ export const verifyAccount = async (user: CreateUser) => { expectFindUserByUsernameSuccess(userResponse, user); // Step 2: Retrieve OTP from database - const otpData = await retrieveOTP(userResponse.body.user.id); + const otpData = await retrieveOTP(userResponse.body.user.id, 'sendEmailVerificationOTP'); // Step 3: Verify OTP - const verifyResponse = await verifyOTP(otpData?.otp, email); - + const verifyResponse = await verifyOTP(otpData, email); expectOTPVerificationSuccess(verifyResponse); }; + +export async function forgetPassword(email: string): Promise { + return supertest(app).post('/api/v1/auth/forget-password').send({ email }); +} + +export function expectForgetPasswordSuccess(response: Response): void { + expect(response).toBeDefined(); + const { statusCode, body } = response; + + expect(statusCode).toBe(STATUS_CODES.OK); + expect(body).toMatchObject({ message: success.FORGET_PASSWORD_EMAIL_SENT }); +} + +export async function resetPassword(resetPassword: ResetPassword): Promise { + return supertest(app).post(`/api/v1/auth/reset-password`).send(resetPassword); +} + +export function expectResetPasswordSuccess(response: Response): void { + expect(response).toBeDefined(); + const { statusCode, body } = response; + + expect(statusCode).toBe(STATUS_CODES.OK); + expect(body).toMatchObject({ message: success.PASSWORD_RESET_SUCCESSFULLY }); +} diff --git a/src/utils/test/otp.utils.ts b/src/utils/test/otp.utils.ts index b36134a..d2b7b35 100644 --- a/src/utils/test/otp.utils.ts +++ b/src/utils/test/otp.utils.ts @@ -2,8 +2,8 @@ import supertest, { Response } from 'supertest'; import { app } from '@/app'; import { STATUS_CODES } from '@/constants'; import { success } from '@/api/v1/auth/auth.constant'; -import { OtpDAL } from '@/api/v1/otp/otp.dal'; -import { OTP, OtpSchema } from '@/api/v1/otp/otp.validation'; +import { OtpSchema, OtpType } from '@/api/v1/otp/otp.validation'; +import { OtpService } from '../../api/v1/otp/otp.service'; export async function requestOTP(email: string): Promise { return supertest(app).post('/api/v1/otp').send({ @@ -17,16 +17,15 @@ export function expectOTPRequestSuccess(response: Response): void { expect(response.body.message).toBe(success.VERIFICATION_EMAIL_SENT); } -export async function retrieveOTP(userId: string): Promise { - return OtpDAL.getOtp({ - userId: userId, - otpType: 'sendEmailVerificationOTP', - }); +export async function retrieveOTP(userId: string, otpType: OtpType): Promise { + const otpDetails = await OtpService.getOtpDetailsByUserId({ userId, otpType }); + const otpData = otpDetails.find((data) => data.otpType === otpType); + return otpData as OtpSchema; } -export async function verifyOTP(otp: OTP | undefined, email: string): Promise { +export async function verifyOTP({ otp, otpType }: OtpSchema, email: string): Promise { return supertest(app).post('/api/v1/otp/verify').send({ - otpType: 'sendEmailVerificationOTP', + otpType, email, otp, });