Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store

docs
/docs
59 changes: 44 additions & 15 deletions backend/src/controllers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ import { logger } from "../utils/logger";
import { config } from "../config";
import crypto from "crypto";
import { sendVerificationEmail } from "../utils/emailClient";
import { sendToEngine } from "../utils/engineClient";
import { assetPrecision } from "../store/market";
import { toBigInt } from "../utils/convert";

const INITIAL_BALANCE_ASSET = "USD";
const INITIAL_BALANCE_AMOUNT = "10000";

async function initializeBalance(userId: string) {
const precision = assetPrecision.get(INITIAL_BALANCE_ASSET);

if (precision == null) {
throw new Error(`Missing precision for ${INITIAL_BALANCE_ASSET}`);
}

const response = await sendToEngine("initialize_balance", {
userId,
asset: INITIAL_BALANCE_ASSET,
amount: toBigInt(INITIAL_BALANCE_AMOUNT, precision).toString(),
});

if (!response.success) {
throw new Error(response.error ?? "Balance initialization failed");
}
}

export async function signup(req: Request, res: Response) {
const parsedBody = signupSchema.safeParse(req.body);
Expand All @@ -38,6 +62,13 @@ export async function signup(req: Request, res: Response) {
data: { email, name, password: hashedPassword },
});

try {
await initializeBalance(user.id);
} catch (error) {
await prisma.user.delete({ where: { id: user.id } });
throw error;
}

const token = crypto.randomBytes(32).toString("hex");

await prisma.verificationToken.create({
Expand All @@ -50,10 +81,16 @@ export async function signup(req: Request, res: Response) {

sendVerificationEmail(user.name, user.email, token);

res.status(201).json({
success: true,
message: "User created successfully. Please check your email to verify your account.",
});
res
.status(201)
.cookie("token", createToken({ id: user.id }), config.cookie)
.json({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
message: "Account created. Check your email to verify it.",
});
} catch (e) {
logger.error("Signup failed", e);
res.status(500).json({ error: "Internal server error" });
Expand Down Expand Up @@ -86,18 +123,14 @@ export async function signin(req: Request, res: Response) {
return;
}

if (!user.emailVerified) {
res.status(403).json({ error: "Email not verified" });
return;
}

res
.status(200)
.cookie("token", createToken({ id: user.id }), config.cookie)
.json({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
});
} catch (e) {
logger.error("Signin failed", e);
Expand Down Expand Up @@ -149,16 +182,12 @@ export async function verifyEmail(req: Request, res: Response) {
data: { usedAt: new Date() },
});

const user = await prisma.user.update({
await prisma.user.update({
where: { id: verification.userId },
data: { emailVerified: true },
select: { id: true, name: true, email: true },
});

res
.status(200)
.cookie("token", createToken({ id: verification.userId }), config.cookie)
.json(user);
res.status(200).json({ message: "Email verified successfully" });
} catch (e) {
logger.error("Email verification failed", e);
res.status(500).json({ error: "Internal server error" });
Expand Down
3 changes: 2 additions & 1 deletion backend/src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ export async function getUserData(req: Request, res: Response) {
}

res.status(200).json({
userId: user.id,
id: user.id,
email: user.email,
name: user.name,
emailVerified: user.emailVerified,
});
} catch (e) {
logger.error("getUserData failed", e);
Expand Down
32 changes: 30 additions & 2 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface TokenPayload {
interface AccessOptions {
types?: PrincipalType[];
scopes?: Scope[];
allowUnverified?: boolean;
}

export function createToken(payload: TokenPayload): string {
Expand Down Expand Up @@ -122,8 +123,8 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
}
}

export function requireAccess({ types, scopes }: AccessOptions) {
return (req: Request, res: Response, next: NextFunction) => {
export function requireAccess({ types, scopes, allowUnverified = false }: AccessOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
const principal = req.principal;

if (!principal) {
Expand All @@ -136,6 +137,33 @@ export function requireAccess({ types, scopes }: AccessOptions) {
return;
}

if (!allowUnverified && principal.type !== "service") {
let emailVerified = principal.emailVerified;

if (emailVerified === undefined && principal.userId) {
const user = await prisma.user.findUnique({
where: { id: principal.userId },
select: { emailVerified: true },
});

if (!user) {
res.status(401).json({ error: "Authentication required" });
return;
}

emailVerified = user.emailVerified;
principal.emailVerified = emailVerified;
}

if (!emailVerified) {
res.status(403).json({
error: "Verify your email to continue",
code: "EMAIL_NOT_VERIFIED",
});
return;
}
}

if (scopes?.length && principal.type !== "session") {
const authorized = scopes.every((scope) => principal.scopes?.includes(scope));

Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export const authRouter = Router();

authRouter.post("/signup", authLimiter, asyncHandler(signup));
authRouter.post("/login", authLimiter, asyncHandler(signin));
authRouter.post("/logout", requireAccess({ types: ["session"] }), asyncHandler(signout));
authRouter.post("/logout", requireAccess({ types: ["session"], allowUnverified: true }), asyncHandler(signout));
authRouter.post("/verify-email", authLimiter, asyncHandler(verifyEmail));
authRouter.post("/resend-verification-email", authLimiter, asyncHandler(resendVerificationEmail));
2 changes: 1 addition & 1 deletion backend/src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getTradeHistory } from "../controllers/user";

export const userRouter = Router();

userRouter.get("/users/me", requireAccess({ types: ["session"] }), asyncHandler(getUserData));
userRouter.get("/users/me", requireAccess({ types: ["session"], allowUnverified: true }), asyncHandler(getUserData));
userRouter.get("/trades", requireAccess({ scopes: ["ORDER_READ"] }), asyncHandler(getTradeHistory));
userRouter.get("/balances", requireAccess({ scopes: ["ACCOUNT_READ"] }), asyncHandler(getBalance));
userRouter.post("/deposits", requireAccess({ types: ["service"] }), asyncHandler(createDeposit));
3 changes: 2 additions & 1 deletion backend/src/types/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export type EngineCommandType =
| "get_open_orders"
| "get_trades"
| "cancel_order"
| "create_deposit";
| "create_deposit"
| "initialize_balance";

export interface EngineRequest {
correlationId: string;
Expand Down
1 change: 1 addition & 0 deletions backend/src/types/principal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export interface Principal {
userId?: string;
keyId?: string;
scopes?: Scope[];
emailVerified?: boolean;
}
30 changes: 29 additions & 1 deletion backend/tests/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const apiKeyPrincipal: Principal = {
userId: "user-1",
keyId: "key-1",
scopes: ["ACCOUNT_READ", "ORDER_CREATE"],
emailVerified: true,
};

test("requireAccess rejects anonymous requests with 401", () => {
Expand All @@ -36,6 +37,30 @@ test("requireAccess rejects disallowed principal types with 403", () => {
expect(next).not.toHaveBeenCalled();
});

test("requireAccess rejects users with unverified email", () => {
const { res, next } = run(
{ type: "session", userId: "user-1", emailVerified: false },
{},
);

expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: "Verify your email to continue",
code: "EMAIL_NOT_VERIFIED",
});
expect(next).not.toHaveBeenCalled();
});

test("requireAccess can allow an unverified session", () => {
const { res, next } = run(
{ type: "session", userId: "user-1", emailVerified: false },
{ allowUnverified: true },
);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test("requireAccess allows matching principal type", () => {
const { res, next } = run(apiKeyPrincipal, { types: ["session", "api_key"] });

Expand All @@ -44,7 +69,10 @@ test("requireAccess allows matching principal type", () => {
});

test("requireAccess lets sessions bypass scope checks", () => {
const { res, next } = run({ type: "session", userId: "u" }, { scopes: ["ORDER_CANCEL"] });
const { res, next } = run(
{ type: "session", userId: "u", emailVerified: true },
{ scopes: ["ORDER_CANCEL"] },
);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
Expand Down
2 changes: 2 additions & 0 deletions engine/src/core/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getUserBalanceHandler } from "../handlers/getUserBalance";
import { getOpenOrdersHandler } from "../handlers/getOpenOrders";
import { getTradesHandler } from "../handlers/getTrades";
import { createDepositHandler } from "../handlers/createDeposit";
import { initializeBalanceHandler } from "../handlers/initializeBalance";

type EngineHandler = (payload: Record<string, unknown>) => Promise<unknown>;

Expand All @@ -17,6 +18,7 @@ const handlers: Record<EngineCommandType, EngineHandler> = {
get_open_orders: getOpenOrdersHandler,
get_trades: getTradesHandler,
create_deposit: createDepositHandler,
initialize_balance: initializeBalanceHandler,
};

export async function dispatch(request: EngineRequest) {
Expand Down
14 changes: 14 additions & 0 deletions engine/src/handlers/initializeBalance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { initializeUserBalance } from "../modules/balance";
import { initializeBalancePayloadSchema } from "../schema";

export async function initializeBalanceHandler(payload: Record<string, unknown>) {
const parsed = initializeBalancePayloadSchema.safeParse(payload);

if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? "Invalid payload");
}

const { userId, asset, amount } = parsed.data;

return initializeUserBalance(userId, asset, amount);
}
Loading