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
+38
View File
@@ -19,9 +19,22 @@ jest.mock("../../app/config/bullBoard.config", () => {
const app = require("../../app");
const { AppError, errorHandler } = require("../../app/middleware/error.middleware");
const db = require("../../app/models");
const { generateToken } = require("../../app/utils/jwt.util");
const authenticated = (accountType = "customer") => {
const user = { id: "usr-self", firstName: "Test", lastName: "User", email: "test@example.com", accountType, accountStatus: "ACTIVE", tokenVersion: 0, emailVerifiedAt: new Date(), profile: null };
const session = { id: "session-self", user_id: user.id, revoked_at: null, expires_at: new Date(Date.now() + 60000), token_version: 0 };
jest.spyOn(db.User, "findByPk").mockResolvedValue(user);
jest.spyOn(db.AuthSession, "findByPk").mockResolvedValue(session);
jest.spyOn(db.UserRole, "findAll").mockResolvedValue([]);
jest.spyOn(db.userPermission, "findAll").mockResolvedValue([]);
return `Bearer ${generateToken({ userId: user.id, sessionId: session.id, tokenVersion: 0 })}`;
};
describe("foundation HTTP behavior", () => {
beforeEach(() => {
jest.restoreAllMocks();
mockCheckDatabase.mockResolvedValue(true);
mockCheckRedis.mockResolvedValue(true);
});
@@ -74,4 +87,29 @@ describe("foundation HTTP behavior", () => {
test("Bull Board rejects unauthenticated requests", async () => {
await request(app).get("/admin/queues").expect(401);
});
test("mounted permission administration rejects unauthenticated requests", async () => {
await request(app).get("/api/v1/permissions").expect(401);
});
test("customer cannot mass-assign account type", async () => {
const token = authenticated("customer");
await request(app).patch("/api/v1/user/me").set("Authorization", token).send({ accountType: "admin" }).expect(400).expect(({ body }) => expect(body.error.code).toBe("UNSAFE_FIELD"));
});
test("customer cannot mutate another user by ID", async () => {
const token = authenticated("customer");
await request(app).patch("/api/v1/user/usr-other").set("Authorization", token).send({ firstName: "Attack" }).expect(403);
});
test("authenticated admin can reach protected Bull Board", async () => {
const token = authenticated("admin");
await request(app).get("/admin/queues").set("Authorization", token).expect(200);
});
test("a suspended account cannot use an otherwise valid access token", async () => {
const token = authenticated("customer");
db.User.findByPk.mockResolvedValue({ id: "usr-self", accountStatus: "SUSPENDED", tokenVersion: 0 });
await request(app).get("/api/v1/auth/me").set("Authorization", token).expect(401);
});
});
+31
View File
@@ -0,0 +1,31 @@
const mockDb = { User: { findOne: jest.fn() } };
const mockCheckPassword = jest.fn();
const mockCreateChallenge = jest.fn();
const mockSendOtp = jest.fn();
jest.mock("../../app/models", () => mockDb);
jest.mock("../../app/utils/hashPassword.util", () => ({ checkPassword: mockCheckPassword }));
jest.mock("../../app/services/auth/otp.service", () => ({ createLoginChallenge: mockCreateChallenge, verifyLoginChallenge: jest.fn() }));
jest.mock("../../app/services/auth/email.service", () => ({ sendLoginOtp: mockSendOtp }));
jest.mock("../../app/utils/idGen.util", () => ({ generateUserId: jest.fn(), generateId: jest.fn() }));
jest.mock("../../app/services/auth/session.service", () => ({ createSession: jest.fn(), rotateSession: jest.fn() }));
jest.mock("../../app/services/auth/oauth.service", () => ({ verifyGoogleToken: jest.fn(), verifyAppleToken: jest.fn() }));
const { beginPasswordLogin } = require("../../app/services/auth/auth.service");
const req = { get: jest.fn(), ip: "127.0.0.1" };
describe("password login boundary", () => {
beforeEach(() => { jest.clearAllMocks(); mockCheckPassword.mockResolvedValue(true); });
test.each(["PENDING_VERIFICATION", "SUSPENDED", "DEACTIVATED"])("rejects %s accounts", async (status) => {
mockDb.User.findOne.mockResolvedValue({ id: "usr", password: "hash", accountStatus: status, accountType: "customer" });
await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: false }, req)).rejects.toMatchObject({ code: "ACCOUNT_NOT_ACTIVE" });
});
test("uses a generic error for missing users and wrong passwords", async () => {
mockDb.User.findOne.mockResolvedValue(null);
await expect(beginPasswordLogin({ email: "x@example.com", password: "wrong" }, req)).rejects.toMatchObject({ code: "INVALID_CREDENTIALS" });
});
test("creates an OTP challenge but no session for valid credentials", async () => {
const user = { id: "usr", password: "hash", accountStatus: "ACTIVE", accountType: "customer" };
mockDb.User.findOne.mockResolvedValue(user); mockCreateChallenge.mockResolvedValue({ challengeId: "challenge", otp: "123456" });
await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: true }, req)).resolves.toEqual({ challengeId: "challenge" });
expect(mockSendOtp).toHaveBeenCalledWith(user, "123456");
});
});
+22
View File
@@ -0,0 +1,22 @@
const jwt = require("jsonwebtoken");
const { generateToken, verifyToken } = require("../../app/utils/jwt.util");
describe("access JWT", () => {
test("contains only session security claims and validates issuer/audience", () => {
const token = generateToken({ userId: "usr-1", sessionId: "sid-1", tokenVersion: 3 });
const payload = verifyToken(token);
expect(payload).toMatchObject({ sub: "usr-1", sid: "sid-1", tokenVersion: 3, iss: "zumri-api", aud: "zumri-clients" });
expect(payload.password).toBeUndefined();
});
test("rejects the wrong issuer and audience", () => {
const token = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "attacker", audience: "wrong", expiresIn: "1m" });
expect(() => verifyToken(token)).toThrow();
});
test("rejects expired and malformed tokens", () => {
const expired = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "zumri-api", audience: "zumri-clients", expiresIn: -1 });
expect(() => verifyToken(expired)).toThrow();
expect(() => verifyToken("not-a-token")).toThrow();
});
});
+22
View File
@@ -0,0 +1,22 @@
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const mockVerifyIdToken = jest.fn();
jest.mock("google-auth-library", () => ({ OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })) }));
const { verifyGoogleToken, verifyAppleToken } = require("../../app/services/auth/oauth.service");
describe("OAuth verifier adapters", () => {
test("uses Google's verified stable subject", async () => {
process.env.GOOGLE_CLIENT_ID = "google-client";
mockVerifyIdToken.mockResolvedValue({ getPayload: () => ({ sub: "google-subject", email: "USER@EXAMPLE.COM", email_verified: true, given_name: "Test", family_name: "User" }) });
await expect(verifyGoogleToken("signed-google-id-token")).resolves.toMatchObject({ subject: "google-subject", email: "user@example.com", emailVerified: true });
});
test("verifies Apple signature, issuer, audience and subject using JWKS", async () => {
process.env.APPLE_CLIENT_ID = "apple-client";
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = publicKey.export({ format: "jwk" }); Object.assign(jwk, { kid: "test-key", alg: "RS256", use: "sig" });
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ keys: [jwk] }) });
const token = jwt.sign({ email: "apple@example.com", email_verified: "true" }, privateKey, { algorithm: "RS256", keyid: "test-key", subject: "apple-subject", issuer: "https://appleid.apple.com", audience: "apple-client", expiresIn: "5m" });
await expect(verifyAppleToken(token)).resolves.toMatchObject({ subject: "apple-subject", emailVerified: true });
});
});
+23
View File
@@ -0,0 +1,23 @@
const mockRedis = { set: jest.fn(), eval: jest.fn() };
jest.mock("../../app/config/redisClient", () => mockRedis);
const { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge } = require("../../app/services/auth/otp.service");
describe("login OTP service", () => {
beforeEach(() => jest.clearAllMocks());
test("generates six numeric digits cryptographically", () => expect(generateOtp()).toMatch(/^\d{6}$/));
test("stores only an OTP hash with TTL", async () => {
const result = await createLoginChallenge({ userId: "usr-1", rememberMe: true, context: {} });
const stored = JSON.parse(mockRedis.set.mock.calls[0][1]);
expect(stored.otpHash).toHaveLength(64);
expect(stored.otp).toBeUndefined();
expect(mockRedis.set.mock.calls[0]).toEqual(expect.arrayContaining(["EX", 900]));
expect(result.otp).toMatch(/^\d{6}$/);
expect(stored.otpHash).toBe(otpHash(result.challengeId, result.otp));
});
test("maps invalid, exhausted, and expired challenges to a generic failure", async () => {
for (const code of [-1, -2, -3]) {
mockRedis.eval.mockResolvedValueOnce([code]);
await expect(verifyLoginChallenge("00000000-0000-4000-8000-000000000000", "000000")).rejects.toMatchObject({ code: "INVALID_OTP", status: 401 });
}
});
});
+10
View File
@@ -0,0 +1,10 @@
const { passwordSchema } = require("../../app/validation/auth.schemas");
describe("password policy", () => {
test("requires length, case, symbol, and four digits", () => {
expect(passwordSchema.safeParse("Strong!1234x").success).toBe(true);
for (const invalid of ["short!1234A", "lowercase!1234", "UPPERCASE!1234", "NoSymbol1234x", "Strong!12xx"]) {
expect(passwordSchema.safeParse(invalid).success).toBe(false);
}
});
});
+37
View File
@@ -0,0 +1,37 @@
const mockTransaction = { LOCK: { UPDATE: "UPDATE" } };
const mockDb = {
AuthSession: { create: jest.fn(), findByPk: jest.fn(), update: jest.fn() },
User: { findByPk: jest.fn() },
sequelize: { transaction: jest.fn((callback) => callback(mockTransaction)) },
};
jest.mock("../../app/models", () => mockDb);
const service = require("../../app/services/auth/session.service");
describe("durable refresh sessions", () => {
beforeEach(() => jest.clearAllMocks());
test("stores a hash and never the raw refresh token", async () => {
mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values }));
const result = await service.createSession({ user: { id: "usr", tokenVersion: 2 }, rememberMe: false });
expect(result.refreshToken).toContain(`${result.session.id}.`);
expect(result.session.refresh_token_hash).toBe(service.hashRefreshToken(result.refreshToken));
expect(JSON.stringify(result.session)).not.toContain(result.refreshToken);
});
test("rotates once and marks the previous session replaced", async () => {
const token = "11111111-1111-4111-8111-111111111111.secret";
const old = { id: token.split(".")[0], user_id: "usr", token_family_id: "22222222-2222-4222-8222-222222222222", refresh_token_hash: service.hashRefreshToken(token), remember_me: false, token_version: 1, expires_at: new Date(Date.now() + 10000), revoked_at: null, save: jest.fn() };
const user = { id: "usr", accountStatus: "ACTIVE", tokenVersion: 1 };
mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.User.findByPk.mockResolvedValue(user);
mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values }));
const result = await service.rotateSession(token);
expect(result.refreshToken).not.toBe(token);
expect(old.revoked_reason).toBe("ROTATED");
expect(old.replaced_by_session_id).toBe(result.session.id);
});
test("replay of a rotated token revokes its family", async () => {
const token = "id.old-token";
const old = { id: "id", token_family_id: "family", revoked_at: new Date(), revoked_reason: "ROTATED", refresh_token_hash: service.hashRefreshToken(token) };
mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.AuthSession.update.mockResolvedValue([1]);
await expect(service.rotateSession(token)).rejects.toMatchObject({ code: "REFRESH_TOKEN_REUSE" });
expect(mockDb.AuthSession.update).toHaveBeenCalledWith(expect.objectContaining({ revoked_reason: "REFRESH_TOKEN_REUSE" }), expect.objectContaining({ where: expect.objectContaining({ token_family_id: "family" }) }));
});
});