updated login flow

This commit is contained in:
2025-07-25 15:38:04 +05:30
parent ee74963058
commit fa1a34372b
7 changed files with 166 additions and 61 deletions

View File

@@ -23,6 +23,7 @@
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@simplewebauthn/server": "^13.1.1", "@simplewebauthn/server": "^13.1.1",
"@types/qs": "^6.9.18", "@types/qs": "^6.9.18",
"argon2": "^0.43.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^3.0.0", "bcryptjs": "^3.0.0",
"fastify": "^5.2.0", "fastify": "^5.2.0",

31
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
'@types/qs': '@types/qs':
specifier: ^6.9.18 specifier: ^6.9.18
version: 6.9.18 version: 6.9.18
argon2:
specifier: ^0.43.1
version: 0.43.1
axios: axios:
specifier: ^1.7.9 specifier: ^1.7.9
version: 1.7.9 version: 1.7.9
@@ -348,6 +351,10 @@ packages:
'@peculiar/asn1-x509@2.3.15': '@peculiar/asn1-x509@2.3.15':
resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==} resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
@@ -596,6 +603,10 @@ packages:
ajv@8.17.1: ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
argon2@0.43.1:
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
engines: {node: '>=16.17.0'}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -940,6 +951,14 @@ packages:
no-case@3.0.4: no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
object-inspect@1.13.4: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1842,6 +1861,8 @@ snapshots:
pvtsutils: 1.3.6 pvtsutils: 1.3.6
tslib: 2.8.1 tslib: 2.8.1
'@phc/format@1.0.0': {}
'@sideway/address@4.1.5': '@sideway/address@4.1.5':
dependencies: dependencies:
'@hapi/hoek': 9.3.0 '@hapi/hoek': 9.3.0
@@ -2222,6 +2243,12 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
argon2@0.43.1:
dependencies:
'@phc/format': 1.0.0
node-addon-api: 8.5.0
node-gyp-build: 4.8.4
argparse@2.0.1: {} argparse@2.0.1: {}
asn1js@3.0.5: asn1js@3.0.5:
@@ -2611,6 +2638,10 @@ snapshots:
lower-case: 2.0.2 lower-case: 2.0.2
tslib: 2.8.1 tslib: 2.8.1
node-addon-api@8.5.0: {}
node-gyp-build@4.8.4: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
obliterator@2.0.4: {} obliterator@2.0.4: {}

View File

@@ -1,61 +1,106 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { getUserByEmail, updateUserInternal } from "../user/user.service"; import { getUserByEmail, getUserByToken } from "../user/user.service";
import { createSession, deleteSession } from "./auth.service"; import { createSession, deleteSession } from "./auth.service";
import { hash, verify } from "argon2";
import { validatePassword } from "../utils/password";
export async function authRoutes(fastify: FastifyInstance) { export async function authRoutes(fastify: FastifyInstance) {
fastify.get( fastify.post(
"/microsoft/token", "/register",
{}, {
schema: {
body: {
type: "object",
required: ["password", "token"],
properties: {
password: { type: "string" },
token: { type: "string" },
},
},
},
},
async (req: FastifyRequest, res: FastifyReply) => { async (req: FastifyRequest, res: FastifyReply) => {
const { password, token } = req.body as {
password: string;
token: string;
};
try { try {
const { token } = const userInDB = await getUserByToken(token);
await fastify.microsoftOauth.getAccessTokenFromAuthorizationCodeFlow( if (!userInDB) return res.code(404).send({ error: "not found" });
req if (new Date() > userInDB.token.expiry)
); return res.code(400).send({ error: "link expired" });
const user = (await fastify.microsoftOauth.userinfo(token)) as { if (!validatePassword(password))
givenname: string; return res.code(400).send({
familyname: string; error:
email: string; "weak password. Make sure the password has atleast 2 uppercase, 2 lowercase, 2 numeric and 2 special characters",
picture: string; });
};
const userInDb = await getUserByEmail(user.email); const hashedPassword = await hash(password);
if (userInDb == null) userInDB.passwordHash = hashedPassword;
return res.code(401).send({ error: "not_allowed" });
await updateUserInternal(userInDb.pid, { await userInDB.save();
firstName: user.givenname,
lastName: user.familyname,
email: user.email,
avatar: user.picture,
});
const session = await createSession(
userInDb.id,
req.ip,
req.headers["user-agent"]
);
return res.code(201).send({ session_token: session.sid });
} catch (err) { } catch (err) {
//@ts-ignore return err;
if (err.data) {
//@ts-ignore
fastify.log.warn(err.data.payload);
return res.code(400).send();
} else {
return err;
}
} }
} }
); );
fastify.delete("/logout", {}, async (req, res) => { fastify.post(
if (!req.headers.authorization) return res.code(200).send(); "/login",
{
schema: {
body: {
type: "object",
required: ["email", "password"],
properties: {
email: { type: "string" },
password: { type: "string" },
},
},
},
},
async (req: FastifyRequest, res: FastifyReply) => {
const { email, password } = req.body as {
email: string;
password: string;
};
const auth = req.headers.authorization.split(" ")[1]; try {
await deleteSession(auth); const userInDB = await getUserByEmail(email);
return res.code(200).send(); if (!userInDB)
}); return res.code(401).send({ error: "invalid email or password" });
const match = await verify(userInDB.passwordHash, password);
if (!match)
return res.code(401).send({ error: "invalid email or password" });
const newSession = await createSession(
userInDB.id,
req.ip,
req.headers["user-agent"]
);
userInDB.lastLogin = new Date();
await userInDB.save();
res.send({ session_token: newSession.sid });
} catch (err) {
return err;
}
}
);
fastify.delete(
"/logout",
{},
async (req: FastifyRequest, res: FastifyReply) => {
if (!req.headers.authorization) return res.code(200).send();
const auth = req.headers.authorization.split(" ")[1];
await deleteSession(auth);
return res.code(200).send();
}
);
} }

View File

@@ -28,6 +28,7 @@ const userSchema = new mongoose.Schema({
type: String, type: String,
required: true, required: true,
}, },
passwordHash: String,
passKeys: [new mongoose.Schema({}, { _id: false, strict: false })], passKeys: [new mongoose.Schema({}, { _id: false, strict: false })],
challenge: new mongoose.Schema( challenge: new mongoose.Schema(
{ {
@@ -68,6 +69,7 @@ const userCore = {
avatar: z.string().optional(), avatar: z.string().optional(),
role: z.enum(roles), role: z.enum(roles),
orgId: z.string().optional(), orgId: z.string().optional(),
password: z.string().optional(),
}; };
const createUserInput = z const createUserInput = z

View File

@@ -4,6 +4,7 @@ import { CreateUserInput, UpdateUserInput, userModel } from "./user.schema";
import { sendMail } from "../utils/mail"; import { sendMail } from "../utils/mail";
import { AuthenticatedUser } from "../auth"; import { AuthenticatedUser } from "../auth";
import { createUserConfig } from "../userConfig/userConfig.service"; import { createUserConfig } from "../userConfig/userConfig.service";
import { hash } from "argon2";
export const ErrUserNotFound = new Error("user not found"); export const ErrUserNotFound = new Error("user not found");
export const ErrOpNotValid = new Error("operation is not valid"); export const ErrOpNotValid = new Error("operation is not valid");
@@ -26,7 +27,8 @@ export async function createUser(
throw ErrMissingOrdId; throw ErrMissingOrdId;
} }
const token = await generateToken(); let hashedPassword = "";
if (input.password) hashedPassword = await hash(input.password);
const newUser = await userModel.create({ const newUser = await userModel.create({
tenantId: user.tenantId, tenantId: user.tenantId,
@@ -34,16 +36,25 @@ export async function createUser(
name: input.firstName + " " + input.lastName, name: input.firstName + " " + input.lastName,
createdAt: new Date(), createdAt: new Date(),
createdBy: user.userId, createdBy: user.userId,
token: { status: input.password ? "active" : "invited",
value: token, passwordHash: hashedPassword,
expiry: new Date(Date.now() + 3600 * 48 * 1000),
},
status: "invited",
...input, ...input,
}); });
await createUserConfig(newUser.id, newUser.tenantId); await createUserConfig(newUser.id, newUser.tenantId);
if (input.password)
return newUser.populate({ path: "orgId", select: "pid name avatar" });
const token = await generateToken();
newUser.token = {
value: token,
expiry: new Date(Date.now() + 3600 * 48 * 1000),
};
await newUser.save();
const sent = await sendMail( const sent = await sendMail(
input.email, input.email,
"You have been invited to Quicker Permtis.", "You have been invited to Quicker Permtis.",

View File

@@ -17,13 +17,3 @@ export async function generateToken(): Promise<string> {
}); });
}); });
} }
export function generateOTP() {
let otp = "";
for (let i = 0; i < 6; i++) {
otp += crypto.randomInt(10);
}
return parseInt(otp);
}

25
src/utils/password.ts Normal file
View File

@@ -0,0 +1,25 @@
export function validatePassword(password: string): boolean {
const charCounts = {
upper: 0,
lower: 0,
number: 0,
special: 0,
};
for (const char of password.split("")) {
if ("ABCDEFGHIJKLMNOPQRSTUVWXYZ".includes(char)) charCounts.upper++;
if ("abcdefghijklmnopqrstuvwxyz".includes(char)) charCounts.lower++;
if ("0123456789".includes(char)) charCounts.number++;
if ("!@#$%^&*()<>?;:{}[]~".includes(char)) charCounts.special++;
}
if (
charCounts.upper >= 2 &&
charCounts.lower >= 2 &&
charCounts.number >= 2 &&
charCounts.special >= 2
)
return true;
return false;
}