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,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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }) }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user