diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9d9468a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test:*)", + "Bash(npx jest:*)" + ] + } +} diff --git a/README.md b/README.md index be1c72e..3c14c8d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,37 @@ npm run dev --- +## Security + +### Authentication & Session Management + +- **JWT access tokens** are short-lived (configured via `JWT_ACCESS_EXPIRES_IN`) and stored in signed `httpOnly` cookies. +- **Refresh tokens** are stored as SHA-256 hashes in MongoDB. The raw token is only ever held in the cookie — never persisted. +- **Token rotation** issues a new refresh token on every `/api/v1/auth/refresh` call (`POST`). The old token is atomically revoked via `findOneAndUpdate` to prevent race conditions between concurrent requests. +- **Token reuse detection** — if a previously rotated (already-revoked) token is replayed, the entire token family for that user is revoked immediately. +- **`rememberMe` preference** is persisted on the `RefreshToken` document and honoured on every rotation; a short-session user is never silently upgraded to a long-lived token. +- **Password change / reset** revokes all active refresh tokens for the user, preventing session hijacking after an account takeover. +- **OTP codes** are generated with `crypto.randomInt` (CSPRNG), not `Math.random`. +- **Expired tokens** are automatically purged from MongoDB via a TTL index on `RefreshToken.expiresAt`. + +### Rate Limiting + +| Scope | Limit | +|---|---| +| Global (all routes) | 100 req / 15 min | +| Auth-sensitive routes (`/login`, `/forgot-password`, `/reset-password`, `/verify-email`, `/resend-verification-email`) | 10 req / 15 min | + +Rate limiting is disabled in the `test` environment. + +### Other + +- **CORS** rejects disallowed origins with an error (not a silent `false`), returning a proper 4xx to non-browser clients. +- **Error responses** never leak internal error messages or stack traces outside of the `development` environment. +- **`REFRESH_TOKEN_BYTES`** is validated at startup via Zod (`min: 32`) to prevent trivially weak tokens from being issued through misconfiguration. +- **`/api/v1/auth/resend-verification-email`** returns a uniform success response regardless of whether the email exists, preventing user enumeration. + +--- + ## Git Workflow ### 1. Create a New Branch diff --git a/src/__tests__/integration/rateLimit.v1.test.ts b/src/__tests__/integration/rateLimit.v1.test.ts index 0421911..b583f22 100644 --- a/src/__tests__/integration/rateLimit.v1.test.ts +++ b/src/__tests__/integration/rateLimit.v1.test.ts @@ -1,10 +1,24 @@ import request from "supertest"; -import app from "@app"; +import express from "express"; +import { rateLimit } from "express-rate-limit"; + +// Build a minimal app with rate limiting enabled (no test skip) and a low +// limit so the test doesn't need to fire hundreds of requests. +const limiterApp = express(); +limiterApp.use( + rateLimit({ + windowMs: 60 * 1000, + limit: 5, + standardHeaders: "draft-7", + legacyHeaders: false, + }), +); +limiterApp.get("/api/health", (_req, res) => res.json({ status: "ok" })); describe("Rate Limiting", () => { it("should return 429 when rate limit is exceeded", async () => { - const requests = Array.from({ length: 110 }, () => - request(app).get("/api/health"), + const requests = Array.from({ length: 10 }, () => + request(limiterApp).get("/api/health"), ); const responses = await Promise.all(requests); diff --git a/src/app.ts b/src/app.ts index d65f394..7754e4a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,33 +5,50 @@ import cors from "cors"; import { httpLogger } from "@config/logger"; import v1Router from "./routes/v1.route"; import errorHandler from "./middlewares/errorHandler"; +import { notFound } from "./middlewares/notFound"; import cookieParser from "cookie-parser"; -import { COOKIE_SECRET, FRONTEND_BASE_URL } from "@config/env"; +import { COOKIE_SECRET, FRONTEND_BASE_URL, NODE_ENV } from "@config/env"; import swaggerUi from "swagger-ui-express"; const app: Application = express(); +const skip = () => NODE_ENV === "test"; + const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 100, standardHeaders: "draft-7", legacyHeaders: false, + skip, + message: { + success: false, + error: "Too many requests, please try again later.", + }, +}); + +// Tighter limit for brute-force-sensitive auth endpoints (login, OTP, password reset). +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 10, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, message: { success: false, error: "Too many requests, please try again later.", }, }); +app.set("trust proxy", 1); app.use(helmet()); app.use(limiter); -app.set("trust proxy", 1); app.use( cors({ origin: (origin, callback) => { if (!origin || origin === FRONTEND_BASE_URL) { callback(null, true); } else { - callback(null, false); + callback(new Error("Not allowed by CORS")); } }, credentials: true, @@ -41,6 +58,16 @@ app.use(cookieParser(COOKIE_SECRET)); app.use(express.json()); app.use(httpLogger); +app.use( + [ + "/api/v1/auth/login", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password", + "/api/v1/auth/verify-email", + "/api/v1/auth/resend-verification-email", + ], + authLimiter, +); app.use("/api/v1", v1Router); export function mountSwagger(spec: object) { app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(spec)); @@ -50,6 +77,7 @@ app.get("/api/health", (req, res) => { res.send({ status: "ok" }); }); +app.use(notFound); app.use((err: Error, req: Request, res: Response, next: NextFunction) => errorHandler(err, req, res, next), ); diff --git a/src/config/env.ts b/src/config/env.ts index ba8379b..2ddead5 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -14,6 +14,7 @@ const envSchema = z.object({ FROM_NAME: z.string(), JWT_ACCESS_EXPIRES_IN: z.string(), COOKIE_SECRET: z.string(), + REFRESH_TOKEN_BYTES: z.coerce.number().min(32).default(64), EMAIL_VERIFICATION_TEMPLATE_KEY: z.string(), PASSWORD_RESET_TEMPLATE_KEY: z.string(), INVITATION_TEMPLATE_KEY: z.string(), @@ -37,6 +38,7 @@ const env = isTest FROM_NAME: "", JWT_ACCESS_EXPIRES_IN: "", COOKIE_SECRET: "testsecret", + REFRESH_TOKEN_BYTES: 64, EMAIL_VERIFICATION_TEMPLATE_KEY: "", PASSWORD_RESET_TEMPLATE_KEY: "", INVITATION_TEMPLATE_KEY: "", @@ -63,6 +65,7 @@ export const { SUPPORT_EMAIL, JWT_ACCESS_EXPIRES_IN, COOKIE_SECRET, + REFRESH_TOKEN_BYTES, EMAIL_VERIFICATION_TEMPLATE_KEY, PASSWORD_RESET_TEMPLATE_KEY, INVITATION_TEMPLATE_KEY, diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 7226c6d..99b22b3 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -23,8 +23,8 @@ const errorHandler = ( logger.error({ err }, "Unhandled error"); return res.status(500).json({ success: false, - error: err.message || "Something went wrong", - stack: NODE_ENV === "development" ? err.stack : "", + error: NODE_ENV === "development" ? err.message : "Internal server error", + stack: NODE_ENV === "development" ? err.stack : undefined, }); } diff --git a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts index 39963e9..cf423a0 100644 --- a/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts +++ b/src/modules/auth/__tests__/integration/refreshToken.v1.test.ts @@ -28,7 +28,7 @@ beforeEach(async () => { describe("Refresh Token", () => { it("should return 401 if refresh token cookie is missing", async () => { - const res = await request(app).get("/api/v1/auth/refresh"); + const res = await request(app).post("/api/v1/auth/refresh"); expect(res.status).toBe(401); expect(res.body.success).toBe(false); @@ -36,7 +36,7 @@ describe("Refresh Token", () => { it("should return 401 if refresh token is invalid", async () => { const res = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", ["refresh_token=invalid_token"]); expect(res.status).toBe(401); @@ -57,7 +57,7 @@ describe("Refresh Token", () => { }); const res = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${rawRefreshToken}`]); expect(res.status).toBe(401); @@ -80,7 +80,7 @@ describe("Refresh Token", () => { }); const res = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${rawRefreshToken}`]); expect(res.status).toBe(401); @@ -103,7 +103,7 @@ describe("Refresh Token", () => { const refreshToken = refreshCookie.split("=")[1].split(";")[0]; const refreshRes = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${refreshToken}`]); expect(refreshRes.status).toBe(200); expect(refreshRes.body.success).toBe(true); @@ -163,7 +163,7 @@ describe("Refresh Token", () => { expect(oldTokenDoc?.revokedAt).toBeNull(); const refreshRes = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${signedValue}`]); expect(refreshRes.status).toBe(200); @@ -195,14 +195,14 @@ describe("Refresh Token", () => { // First refresh - should succeed const firstRefreshRes = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${refreshToken}`]); expect(firstRefreshRes.status).toBe(200); // Try to reuse the old token - should fail const secondRefreshRes = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", [`refresh_token=${refreshToken}`]); expect(secondRefreshRes.status).toBe(401); @@ -211,7 +211,7 @@ describe("Refresh Token", () => { it("should clear cookies when refresh token is invalid", async () => { const res = await request(app) - .get("/api/v1/auth/refresh") + .post("/api/v1/auth/refresh") .set("Cookie", ["refresh_token=invalid_token"]); expect(res.status).toBe(401); diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index daa93fd..9eabf64 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -128,6 +128,6 @@ describe("Email Verification", () => { email: user.email, }); - expect(resendVerificationCodeResponse.status).toBe(400); + expect(resendVerificationCodeResponse.status).toBe(200); }); }); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d2ef5b0..4a6febf 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -60,7 +60,11 @@ export const verifyEmailVerificationCode = routeTryCatcher( export const resendEmailVerificationCode = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { const user = await UserService.getUserByEmail(req.body.email); - if (!user) return next(AppError.badRequest("User not found")); + if (!user) + return res.status(200).json({ + success: true, + data: { emailSent: true }, + }); const result = await AuthService.sendVerificationEmail(user); if (!result.success) return next( @@ -116,10 +120,13 @@ export const refreshToken = routeTryCatcher( const token = req.signedCookies?.refresh_token; if (!token) return next(AppError.unauthorized("Unauthenticated")); - const result = await AuthService.rotateRefreshToken(token, req.ip); + const result = await AuthService.rotateRefreshToken( + token, + req.ip, + req.get("User-Agent"), + ); if (!result.success) { - res.clearCookie("access_token"); - res.clearCookie("refresh_token"); + clearAuthCookies(res); return next(AppError.unauthorized(result.error || "Session expired")); } diff --git a/src/modules/auth/auth.docs.ts b/src/modules/auth/auth.docs.ts index 4c23f98..2c4ab11 100644 --- a/src/modules/auth/auth.docs.ts +++ b/src/modules/auth/auth.docs.ts @@ -64,7 +64,7 @@ export type AuthApiSpec = Tspec.DefineApiSpec<{ }; }; "/refresh": { - get: { + post: { summary: "Refresh token"; body: Record; responses: { diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 2f25def..0b83c0f 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -10,16 +10,13 @@ import { import { IUser } from "@modules/user/user.types"; import { sendEmailWithTemplate } from "@services/email.service"; import { ISuccessPayload, IErrorPayload } from "src/types"; -import { hashWithCrypto } from "@utils/encryptors"; import { RefreshTokenModel } from "./refreshToken.model"; -import { DEFAULT_REFRESH_DAYS } from "@config/constants"; -import { generateRandomTokenWithCrypto } from "@utils/generators"; import { EMAIL_VERIFICATION_TEMPLATE_KEY, PASSWORD_RESET_TEMPLATE_KEY, } from "@config/env"; import { - generateAccessToken, + createTokensForUser, rotateRefreshToken, revokeRefreshToken, } from "./utils/auth.tokens"; @@ -74,8 +71,11 @@ const AuthService = { mail_template_key: EMAIL_VERIFICATION_TEMPLATE_KEY, template_alias: "email-verification", }); + if (!emailSentResponse.success) { + return { success: false, error: "Failed to send verification email" }; + } return { - success: emailSentResponse.success, + success: true, data: { emailSent: emailSentResponse.emailSent || false }, }; } catch (err) { @@ -116,42 +116,23 @@ const AuthService = { userAgent?: string | undefined; }, ) => { - const accessToken = generateAccessToken({ - id: user._id.toString(), - email: user.email, - }); - - const rawRefreshToken = generateRandomTokenWithCrypto( - Number(process.env.REFRESH_TOKEN_BYTES || 64), - ); - const tokenHash = hashWithCrypto(rawRefreshToken); - - const expiresAt = new Date( - Date.now() + - (rememberMe ? DEFAULT_REFRESH_DAYS : 7) * 24 * 60 * 60 * 1000, - ); - - const refreshDoc = await RefreshTokenModel.create({ - user: user._id, - tokenHash, - expiresAt, - createdByIp: metaData?.ip, - userAgent: metaData?.userAgent, - }); + const { accessToken, refreshToken, refreshTokenId, expiresAt } = + await createTokensForUser( + user, + rememberMe, + metaData?.ip, + metaData?.userAgent, + ); return { success: true, - data: { - accessToken, - refreshToken: rawRefreshToken, - refreshTokenId: refreshDoc._id, - expiresAt, - }, + data: { accessToken, refreshToken, refreshTokenId, expiresAt }, }; }, rotateRefreshToken: async ( rawRefreshToken: string, ip?: string, + userAgent?: string, ): Promise< | { success: true; @@ -166,7 +147,7 @@ const AuthService = { > => { try { const { refreshToken, refreshTokenId, expiresAt, accessToken } = - await rotateRefreshToken(rawRefreshToken, ip); + await rotateRefreshToken(rawRefreshToken, ip, userAgent); return { success: true, @@ -222,6 +203,12 @@ const AuthService = { user.password = newPassword; await user.save(); + // Invalidate all active sessions so a compromised token can't be reused. + await RefreshTokenModel.updateMany( + { user: user._id, revokedAt: null }, + { revokedAt: new Date(), reason: "password_changed" }, + ); + return { success: true, data: { message: "Password changed successfully" }, @@ -313,6 +300,12 @@ const AuthService = { user.password = newPassword; await user.save(); + // Invalidate all active sessions so a compromised token can't be reused. + await RefreshTokenModel.updateMany( + { user: user._id, revokedAt: null }, + { revokedAt: new Date(), reason: "password_reset" }, + ); + return { success: true, data: { message: "Password reset successfully" }, diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index a88fcf3..e36a66e 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -17,9 +17,10 @@ export interface IRefreshTokenDoc extends Document { createdAt: Date; createdByIp?: string; userAgent?: string; + rememberMe?: boolean; revokedAt?: Date | null; revokedByIp?: string | null; - replacedByToken?: string | null; + replacedByToken?: mongoose.Types.ObjectId | null; reason?: string | null; } diff --git a/src/modules/auth/refreshToken.model.ts b/src/modules/auth/refreshToken.model.ts index 35d24b9..8bc2c4e 100644 --- a/src/modules/auth/refreshToken.model.ts +++ b/src/modules/auth/refreshToken.model.ts @@ -8,6 +8,7 @@ const RefreshTokenSchema = new Schema({ createdAt: { type: Date, default: () => new Date() }, createdByIp: { type: String }, userAgent: { type: String }, + rememberMe: { type: Boolean, default: false }, revokedAt: { type: Date, default: null }, revokedByIp: { type: String, default: null }, replacedByToken: { @@ -18,6 +19,8 @@ const RefreshTokenSchema = new Schema({ reason: { type: String, default: null }, }); +RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + export const RefreshTokenModel = mongoose.model( "RefreshToken", RefreshTokenSchema, diff --git a/src/modules/auth/routes/auth.v1.routes.ts b/src/modules/auth/routes/auth.v1.routes.ts index 06577c8..ab2b6be 100644 --- a/src/modules/auth/routes/auth.v1.routes.ts +++ b/src/modules/auth/routes/auth.v1.routes.ts @@ -37,7 +37,7 @@ authRouter.post( resendEmailVerificationCode, ); authRouter.post("/login", validateResource(loginSchema), loginUser); -authRouter.get("/refresh", refreshToken); +authRouter.post("/refresh", refreshToken); authRouter.get("/me", authenticate, getCurrentUser); authRouter.post("/logout", logoutUser); authRouter.post( diff --git a/src/modules/auth/utils/auth.tokens.ts b/src/modules/auth/utils/auth.tokens.ts index 9653606..c1adc68 100644 --- a/src/modules/auth/utils/auth.tokens.ts +++ b/src/modules/auth/utils/auth.tokens.ts @@ -1,4 +1,8 @@ -import { JWT_ACCESS_EXPIRES_IN, JWT_SECRET } from "@config/env"; +import { + JWT_ACCESS_EXPIRES_IN, + JWT_SECRET, + REFRESH_TOKEN_BYTES, +} from "@config/env"; import * as jwt from "jsonwebtoken"; import mongoose from "mongoose"; import { generateRandomTokenWithCrypto } from "@utils/generators"; @@ -6,7 +10,6 @@ import { hashWithCrypto } from "@utils/encryptors"; import { IUser } from "@modules/user/user.types"; import { AccessPayload } from "../auth.types"; import { DEFAULT_REFRESH_DAYS } from "@config/constants"; -import { convertTimeToMilliseconds } from "@utils/index"; import { RefreshTokenModel } from "../refreshToken.model"; import UserService from "@modules/user/user.service"; @@ -25,9 +28,7 @@ export async function createTokensForUser( email: user.email, }); - const rawRefreshToken = generateRandomTokenWithCrypto( - Number(process.env.REFRESH_TOKEN_BYTES || 64), - ); + const rawRefreshToken = generateRandomTokenWithCrypto(REFRESH_TOKEN_BYTES); const tokenHash = hashWithCrypto(rawRefreshToken); const expiresAt = new Date( @@ -40,6 +41,7 @@ export async function createTokensForUser( expiresAt, createdByIp: ip, userAgent, + rememberMe, }); return { @@ -56,12 +58,23 @@ export async function rotateRefreshToken( userAgent?: string, ) { const oldHash = hashWithCrypto(oldToken); - const existing = await RefreshTokenModel.findOne({ tokenHash: oldHash }); - if (!existing || existing.revokedAt) { - if (existing && existing.revokedAt) { + // Atomically revoke the token if it is still active. + // Using findOneAndUpdate as the atomic gate eliminates the race condition + // where two concurrent requests could both pass the revokedAt === null check. + const existing = await RefreshTokenModel.findOneAndUpdate( + { tokenHash: oldHash, revokedAt: null }, + { $set: { revokedAt: new Date(), revokedByIp: ip } }, + { new: false }, + ); + + if (!existing) { + // Token not found or already revoked — check whether this is a reuse attack. + const doc = await RefreshTokenModel.findOne({ tokenHash: oldHash }); + if (doc?.revokedAt) { + // A previously valid token is being replayed — revoke the entire family. await RefreshTokenModel.updateMany( - { user: existing.user, revokedAt: null }, + { user: doc.user, revokedAt: null }, { revokedAt: new Date(), reason: "reused" }, ); } @@ -69,20 +82,23 @@ export async function rotateRefreshToken( } if (existing.expiresAt < new Date()) { - await existing.updateOne({ - revokedAt: new Date(), - reason: "expired", - }); + await RefreshTokenModel.updateOne( + { _id: existing._id }, + { reason: "expired" }, + ); throw new Error("Refresh token expired"); } + const user = await UserService.getUserById(existing.user.toString()); if (!user) throw new Error("User not found!"); - const rawRefreshToken = generateRandomTokenWithCrypto( - Number(process.env.REFRESH_TOKEN_BYTES || 64), - ); + + const rawRefreshToken = generateRandomTokenWithCrypto(REFRESH_TOKEN_BYTES); const newHash = hashWithCrypto(rawRefreshToken); + + // Preserve the original session length the user chose at login. + const rememberMe = existing.rememberMe ?? false; const expiresAt = new Date( - Date.now() + convertTimeToMilliseconds(720, "hours"), + Date.now() + (rememberMe ? DEFAULT_REFRESH_DAYS : 7) * 24 * 60 * 60 * 1000, ); const session = await mongoose.startSession(); @@ -97,15 +113,18 @@ export async function rotateRefreshToken( expiresAt, createdByIp: ip, userAgent, + rememberMe, }, ], { session }, ); - existing.revokedAt = new Date(); - existing.revokedByIp = ip as string; - existing.replacedByToken = newRefreshToken[0]?._id?.toString() as string; - await existing.save({ session }); + // Update the old token's chain pointer and reason inside the transaction. + await RefreshTokenModel.updateOne( + { _id: existing._id }, + { replacedByToken: newRefreshToken[0]?._id, reason: "rotated" }, + { session }, + ); await session.commitTransaction(); @@ -114,7 +133,7 @@ export async function rotateRefreshToken( refreshTokenId: newRefreshToken[0]?._id, accessToken: generateAccessToken({ id: user._id.toString(), - email: user?.email, + email: user.email, }), expiresAt, }; @@ -124,7 +143,7 @@ export async function rotateRefreshToken( } throw err; } finally { - session.endSession(); + await session.endSession(); } } diff --git a/src/modules/user/routes/user.v1.routes.ts b/src/modules/user/routes/user.v1.routes.ts new file mode 100644 index 0000000..b9b3242 --- /dev/null +++ b/src/modules/user/routes/user.v1.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import authenticate from "@middlewares/authenticate"; +import validateResource from "@middlewares/validators"; +import { updateUserSchema } from "../user.validators"; +import { updateMe } from "../user.controller"; + +const userRouter = Router(); + +userRouter.put( + "/me", + authenticate, + validateResource(updateUserSchema), + updateMe, +); + +export default userRouter; diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts new file mode 100644 index 0000000..38d0a35 --- /dev/null +++ b/src/modules/user/user.controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from "express"; +import { routeTryCatcher } from "@utils/routeTryCatcher"; +import UserService from "./user.service"; +import { serializeUser } from "./user.utils"; +import { UpdateUserInput } from "./user.validators"; + +export const updateMe = routeTryCatcher(async (req: Request, res: Response) => { + const input: UpdateUserInput = req.body; + const update = Object.fromEntries( + Object.entries(input).filter(([, v]) => v !== undefined), + ) as { firstName?: string; lastName?: string; isOnboarded?: boolean }; + const updated = await UserService.updateUser( + req.user!._id.toString(), + update, + ); + return res.status(200).json({ success: true, data: serializeUser(updated!) }); +}); diff --git a/src/modules/user/user.docs.ts b/src/modules/user/user.docs.ts new file mode 100644 index 0000000..e74d7a2 --- /dev/null +++ b/src/modules/user/user.docs.ts @@ -0,0 +1,32 @@ +import { Tspec } from "tspec"; +import { ISuccessPayload, IErrorPayload } from "src/types"; +import { UpdateUserInput } from "./user.validators"; + +type UpdateUserOutput = { + id: string; + email: string; + firstName: string; + lastName: string; + isOnboarded: boolean; + isEmailVerified: boolean; + createdAt: Date; + updatedAt: Date; +}; + +export type UserApiSpec = Tspec.DefineApiSpec<{ + basePath: "/api/v1/users"; + tags: ["Users"]; + paths: { + "/me": { + put: { + summary: "Update the currently authenticated user"; + body: UpdateUserInput; + responses: { + 200: ISuccessPayload; + 400: IErrorPayload; + 401: IErrorPayload; + }; + }; + }; + }; +}>; diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 8a74fa7..6e19996 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -1,4 +1,5 @@ import mongoose, { CallbackError, Schema } from "mongoose"; +import crypto from "crypto"; import { IUser } from "./user.types"; import { hashWithBcrypt, hashWithCrypto } from "@utils/encryptors"; import { convertTimeToMilliseconds } from "@utils/index"; @@ -19,6 +20,7 @@ const userSchema = new Schema( ref: "TimeEntry", default: null, }, + isOnboarded: { type: Boolean, default: false }, }, { timestamps: true }, ); @@ -63,7 +65,7 @@ userSchema.pre("save", async function (next) { }); function generateAndHashSixDigitCode(): { code: string; hashedCode: string } { - const code = Math.floor(100000 + Math.random() * 900000).toString(); + const code = crypto.randomInt(100000, 1000000).toString(); const hashedCode = hashWithCrypto(code); return { code, hashedCode }; } diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index d5021cb..53c2417 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -10,6 +10,13 @@ const UserService = { return await UserModel.findById(id); }, + updateUser: async ( + id: string, + input: { firstName?: string; lastName?: string; isOnboarded?: boolean }, + ): Promise => { + return await UserModel.findByIdAndUpdate(id, input, { new: true }); + }, + createUser: async ( input: Pick, ): Promise => { diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index b96ca73..19dc581 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -21,4 +21,5 @@ export interface IUser extends mongoose.Document { generatePasswordResetCode: () => string; verifyPasswordResetCode: (code: string) => boolean; clearPasswordResetData: () => Promise; + isOnboarded: boolean; } diff --git a/src/modules/user/user.utils.ts b/src/modules/user/user.utils.ts index f513f46..14f1f5c 100644 --- a/src/modules/user/user.utils.ts +++ b/src/modules/user/user.utils.ts @@ -15,6 +15,7 @@ export function serializeUser(user: IUser) { updatedAt: obj.updatedAt, firstName: obj.firstName, lastName: obj.lastName, + isOnboarded: obj.isOnboarded, }; return safe; diff --git a/src/modules/user/user.validators.ts b/src/modules/user/user.validators.ts new file mode 100644 index 0000000..0ee6d57 --- /dev/null +++ b/src/modules/user/user.validators.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const updateUserSchema = z.object({ + firstName: z.string().min(1).max(100).optional(), + lastName: z.string().min(1).max(100).optional(), + isOnboarded: z.boolean().optional(), +}); + +export type UpdateUserInput = z.infer; diff --git a/src/routes/v1.route.ts b/src/routes/v1.route.ts index a41da93..1fbfe20 100644 --- a/src/routes/v1.route.ts +++ b/src/routes/v1.route.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import authRouter from "@modules/auth/routes/auth.v1.routes"; +import userRouter from "@modules/user/routes/user.v1.routes"; import organizationRouter from "@modules/organization/routes/organization.v1.routes"; import membershipRouter from "@modules/membership/routes/membership.v1.routes"; import tagRouter from "@modules/tag/routes/tag.v1.routes"; @@ -10,6 +11,7 @@ import timeEntryRouter from "@modules/time-entry/routes/time-entry.v1.routes"; const v1Router = Router(); v1Router.use("/auth", authRouter); +v1Router.use("/users", userRouter); v1Router.use("/org", organizationRouter); v1Router.use("/membership", membershipRouter); v1Router.use("/tags", tagRouter);