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:
@@ -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 };
|
||||
Reference in New Issue
Block a user