feat: implement identity and security features

- Added account types and privileged account types constants.
- Created admin user controller for updating user security fields.
- Developed role assignment controller for managing user roles.
- Implemented validation middleware for request schemas.
- Defined user role and auth session models for database interactions.
- Created services for authentication, email notifications, and OTP handling.
- Developed OAuth service for Google and Apple authentication.
- Added JWT utility functions for token generation and verification.
- Implemented comprehensive tests for authentication, session management, and password policies.
- Created migration for updating user schema and adding new tables for auth sessions and user identities.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 13:56:18 +05:30
parent 267e80e2ec
commit 9d3d431416
54 changed files with 1389 additions and 1076 deletions
+65
View File
@@ -0,0 +1,65 @@
const crypto = require("crypto");
const db = require("../../models");
const { checkPassword } = require("../../utils/hashPassword.util");
const { generateToken } = require("../../utils/jwt.util");
const { generateUserId, generateId } = require("../../utils/idGen.util");
const { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES } = require("../../constants/accountTypes");
const { createLoginChallenge, verifyLoginChallenge } = require("./otp.service");
const { createSession, rotateSession } = require("./session.service");
const { sendLoginOtp } = require("./email.service");
const oauth = require("./oauth.service");
const authError = (code = "INVALID_CREDENTIALS", status = 401) => Object.assign(new Error(code === "ACCOUNT_NOT_ACTIVE" ? "Account is not active" : "Authentication failed"), { code, status });
const contextFromRequest = (req, deviceName) => ({ deviceName, userAgent: req.get("user-agent")?.slice(0, 500), ipAddress: req.ip });
const tokenPair = (user, session, refreshToken) => ({ accessToken: generateToken({ userId: user.id, sessionId: session.id, tokenVersion: user.tokenVersion }), refreshToken, refreshExpiresAt: session.expires_at });
const beginPasswordLogin = async ({ email, password, rememberMe, deviceName }, req, allowedTypes) => {
const user = await db.User.findOne({ where: { email: email.toLowerCase() } });
const valid = user?.password && await checkPassword(password, user.password);
if (!user || !valid || (allowedTypes && !allowedTypes.includes(user.accountType))) throw authError();
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const context = contextFromRequest(req, deviceName);
const challenge = await createLoginChallenge({ userId: user.id, rememberMe, context });
await sendLoginOtp(user, challenge.otp);
return { challengeId: challenge.challengeId };
};
const completeOtpLogin = async ({ challengeId, otp }, req) => {
const challenge = await verifyLoginChallenge(challengeId, otp);
return db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(challenge.userId, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: challenge.rememberMe, ...challenge.context, ...contextFromRequest(req, challenge.context.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, ...tokenPair(user, created.session, created.refreshToken) };
});
};
const refresh = async (refreshToken, req) => {
const rotated = await rotateSession(refreshToken, contextFromRequest(req));
return { user: rotated.user, ...tokenPair(rotated.user, rotated.session, rotated.refreshToken) };
};
const authenticateOAuth = async (provider, input, req) => {
const verified = provider === "google" ? await oauth.verifyGoogleToken(input.idToken) : await oauth.verifyAppleToken(input.idToken);
return db.sequelize.transaction(async (transaction) => {
let identity = await db.UserIdentity.findOne({ where: { provider, provider_subject: verified.subject }, transaction, lock: transaction.LOCK.UPDATE });
let user = identity && await db.User.findByPk(identity.user_id, { transaction });
if (!user) {
if (!verified.email || !verified.emailVerified) throw authError("OAUTH_LINK_REQUIRED", 403);
user = await db.User.findOne({ where: { email: verified.email }, transaction, lock: transaction.LOCK.UPDATE });
if (user && (PRIVILEGED_ACCOUNT_TYPES.includes(user.accountType) || user.accountType === ACCOUNT_TYPES.RIDER)) throw authError("OAUTH_LINK_REQUIRED", 403);
if (!user) {
user = await db.User.create({ id: generateUserId(), firstName: verified.firstName || input.firstName || "ZUMRI", lastName: verified.lastName || input.lastName || "Customer", email: verified.email, password: null, accountType: ACCOUNT_TYPES.CUSTOMER, accountStatus: "ACTIVE", emailVerifiedAt: new Date(), tokenVersion: 0 }, { transaction });
await db.Profile.create({ profile_id: generateId(), user_id: user.id, theme: "light", notificationsEnabled: true }, { transaction });
}
identity = await db.UserIdentity.create({ id: crypto.randomUUID(), user_id: user.id, provider, provider_subject: verified.subject, provider_email: verified.email }, { transaction });
}
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: input.rememberMe, ...contextFromRequest(req, input.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, identity, ...tokenPair(user, created.session, created.refreshToken) };
});
};
module.exports = { beginPasswordLogin, completeOtpLogin, refresh, authenticateOAuth, contextFromRequest, tokenPair };
+6
View File
@@ -0,0 +1,6 @@
const { sendMail } = require("../../utils/mail.util");
const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` });
const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." });
module.exports = { sendLoginOtp, sendPasswordChanged };
+31
View File
@@ -0,0 +1,31 @@
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library");
let appleKeys;
let appleKeysAt = 0;
const verifyGoogleToken = async (idToken) => {
if (!process.env.GOOGLE_CLIENT_ID) throw Object.assign(new Error("Google authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const ticket = await new OAuth2Client(process.env.GOOGLE_CLIENT_ID).verifyIdToken({ idToken, audience: process.env.GOOGLE_CLIENT_ID });
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email || payload.email_verified !== true) throw new Error("Invalid Google identity token");
return { subject: payload.sub, email: payload.email.toLowerCase(), emailVerified: true, firstName: payload.given_name, lastName: payload.family_name };
};
const getAppleKeys = async () => {
if (appleKeys && Date.now() - appleKeysAt < 3600000) return appleKeys;
const response = await fetch("https://appleid.apple.com/auth/keys");
if (!response.ok) throw new Error("Apple key service unavailable");
appleKeys = (await response.json()).keys; appleKeysAt = Date.now(); return appleKeys;
};
const verifyAppleToken = async (idToken) => {
if (!process.env.APPLE_CLIENT_ID) throw Object.assign(new Error("Apple authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const decoded = jwt.decode(idToken, { complete: true });
const key = (await getAppleKeys()).find((candidate) => candidate.kid === decoded?.header?.kid && candidate.alg === "RS256");
if (!key) throw new Error("Invalid Apple identity token");
const payload = jwt.verify(idToken, crypto.createPublicKey({ key, format: "jwk" }), { algorithms: ["RS256"], issuer: "https://appleid.apple.com", audience: process.env.APPLE_CLIENT_ID });
if (!payload.sub) throw new Error("Invalid Apple identity token");
return { subject: payload.sub, email: payload.email?.toLowerCase(), emailVerified: payload.email_verified === true || payload.email_verified === "true" };
};
module.exports = { verifyGoogleToken, verifyAppleToken };
+29
View File
@@ -0,0 +1,29 @@
const crypto = require("crypto");
const redis = require("../../config/redisClient");
const otpHash = (challengeId, otp) => crypto.createHmac("sha256", process.env.JWT_SECRET).update(`${challengeId}:${otp}`).digest("hex");
const generateOtp = () => crypto.randomInt(0, 1000000).toString().padStart(6, "0");
const createLoginChallenge = async ({ userId, rememberMe, context }) => {
const challengeId = crypto.randomUUID();
const otp = generateOtp();
await redis.set(`login:${challengeId}`, JSON.stringify({ userId, otpHash: otpHash(challengeId, otp), attempts: 0, rememberMe, context }), "EX", Number(process.env.LOGIN_OTP_TTL_SECONDS || 900));
return { challengeId, otp };
};
const VERIFY_SCRIPT = `
local raw=redis.call('GET',KEYS[1]); if not raw then return {-3} end
local value=cjson.decode(raw)
if value.otpHash==ARGV[1] then redis.call('DEL',KEYS[1]); return {1,value.userId,cjson.encode(value)} end
value.attempts=(value.attempts or 0)+1
if value.attempts>=tonumber(ARGV[2]) then redis.call('DEL',KEYS[1]); return {-2} end
redis.call('SET',KEYS[1],cjson.encode(value),'KEEPTTL'); return {-1}
`;
const verifyLoginChallenge = async (challengeId, otp) => {
const result = await redis.eval(VERIFY_SCRIPT, 1, `login:${challengeId}`, otpHash(challengeId, otp), Number(process.env.LOGIN_OTP_MAX_ATTEMPTS || 5));
if (Number(result[0]) !== 1) throw Object.assign(new Error("Invalid or expired challenge"), { code: "INVALID_OTP", status: 401 });
const stored = JSON.parse(result[2]);
return { userId: result[1], rememberMe: Boolean(stored.rememberMe), context: stored.context || {} };
};
module.exports = { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge };
+59
View File
@@ -0,0 +1,59 @@
const crypto = require("crypto");
const db = require("../../models");
const digest = (token) => crypto.createHash("sha256").update(token).digest("hex");
const safeEqual = (left, right) => left?.length === right?.length && crypto.timingSafeEqual(Buffer.from(left), Buffer.from(right));
const rawToken = (id) => `${id}.${crypto.randomBytes(48).toString("base64url")}`;
const tokenId = (token) => typeof token === "string" ? token.split(".", 1)[0] : null;
const ttlDays = (rememberMe) => Number(process.env[rememberMe ? "REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS" : "REFRESH_TOKEN_TTL_DAYS"] || (rememberMe ? 30 : 7));
const createSession = async ({ user, rememberMe = false, deviceName, userAgent, ipAddress, familyId, transaction }) => {
const id = crypto.randomUUID();
const refreshToken = rawToken(id);
const expiresAt = new Date(Date.now() + ttlDays(rememberMe) * 86400000);
const session = await db.AuthSession.create({
id, user_id: user.id, token_family_id: familyId || crypto.randomUUID(), refresh_token_hash: digest(refreshToken),
device_name: deviceName, user_agent: userAgent, ip_address: ipAddress, remember_me: rememberMe,
token_version: user.tokenVersion, last_used_at: new Date(), expires_at: expiresAt,
}, { transaction });
return { session, refreshToken, expiresAt };
};
const revokeFamily = async (familyId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason },
{ where: { token_family_id: familyId, revoked_at: null }, transaction },
);
const rotateSession = async (token, context = {}) => db.sequelize.transaction(async (transaction) => {
const id = tokenId(token);
if (!id) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
const session = await db.AuthSession.findByPk(id, { transaction, lock: transaction.LOCK.UPDATE });
if (!session) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (!safeEqual(digest(token), session.refresh_token_hash)) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (session.revoked_at) {
if (session.revoked_reason === "ROTATED") await revokeFamily(session.token_family_id, "REFRESH_TOKEN_REUSE", transaction);
throw Object.assign(new Error("Invalid session"), { code: session.revoked_reason === "ROTATED" ? "REFRESH_TOKEN_REUSE" : "INVALID_SESSION", status: 401 });
}
if (session.expires_at <= new Date()) {
session.revoked_at = new Date(); session.revoked_reason = "EXPIRED"; await session.save({ transaction });
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const user = await db.User.findByPk(session.user_id, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== session.token_version) {
await revokeFamily(session.token_family_id, "ACCOUNT_OR_TOKEN_VERSION_INVALID", transaction);
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const replacement = await createSession({ user, rememberMe: session.remember_me, familyId: session.token_family_id, ...context, transaction });
session.revoked_at = new Date(); session.revoked_reason = "ROTATED"; session.replaced_by_session_id = replacement.session.id;
session.last_used_at = new Date(); await session.save({ transaction });
return { ...replacement, user };
});
const revokeSession = async (id, reason = "LOGOUT", transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { id, revoked_at: null }, transaction },
);
const revokeAllUserSessions = async (userId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { user_id: userId, revoked_at: null }, transaction },
);
module.exports = { createSession, rotateSession, revokeSession, revokeFamily, revokeAllUserSessions, hashRefreshToken: digest, tokenId };