mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
feat: add OTP plugin
This commit is contained in:
@@ -77,3 +77,19 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
|
||||
try {
|
||||
await fn;
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (instance) {
|
||||
if (e instanceof instance) {
|
||||
return true;
|
||||
}
|
||||
// if instance given but not what expected, throw
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
160
app/src/plugins/auth/otp.plugin.spec.ts
Normal file
160
app/src/plugins/auth/otp.plugin.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { otp } from "./otp.plugin";
|
||||
import { createApp } from "core/test/utils";
|
||||
|
||||
describe("otp plugin", () => {
|
||||
test("should not work if auth is not enabled", async () => {
|
||||
const app = createApp({
|
||||
options: {
|
||||
plugins: [otp({ showActualErrors: true })],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
const res = await app.server.request("/api/auth/otp/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com" }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should generate a token", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
plugins: [otp({ showActualErrors: true })],
|
||||
drivers: {
|
||||
email: {
|
||||
send: async (to) => {
|
||||
expect(to).toBe("test@test.com");
|
||||
called();
|
||||
},
|
||||
},
|
||||
},
|
||||
seed: async (ctx) => {
|
||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/api/auth/otp/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = (await res.json()) as any;
|
||||
expect(data.data.code).toBeDefined();
|
||||
expect(data.data.code.length).toBe(6);
|
||||
expect(data.data.code.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(data.data.email).toBe("test@test.com");
|
||||
expect(called).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should login with a code", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
plugins: [otp({ showActualErrors: true })],
|
||||
drivers: {
|
||||
email: {
|
||||
send: async () => {},
|
||||
},
|
||||
},
|
||||
seed: async (ctx) => {
|
||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/api/auth/otp/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com" }),
|
||||
});
|
||||
const data = (await res.json()) as any;
|
||||
|
||||
{
|
||||
const res = await app.server.request("/api/auth/otp/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com", code: data.data.code }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("set-cookie")).toBeDefined();
|
||||
const userData = (await res.json()) as any;
|
||||
expect(userData.user.email).toBe("test@test.com");
|
||||
expect(userData.token).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should register with a code", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
plugins: [otp({ showActualErrors: true })],
|
||||
drivers: {
|
||||
email: {
|
||||
send: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/api/auth/otp/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com" }),
|
||||
});
|
||||
const data = (await res.json()) as any;
|
||||
|
||||
{
|
||||
const res = await app.server.request("/api/auth/otp/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: "test@test.com", code: data.data.code }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("set-cookie")).toBeDefined();
|
||||
const userData = (await res.json()) as any;
|
||||
expect(userData.user.email).toBe("test@test.com");
|
||||
expect(userData.token).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
314
app/src/plugins/auth/otp.plugin.ts
Normal file
314
app/src/plugins/auth/otp.plugin.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
datetime,
|
||||
em,
|
||||
entity,
|
||||
enumm,
|
||||
Exception,
|
||||
text,
|
||||
type App,
|
||||
type AppPlugin,
|
||||
type DB,
|
||||
type FieldSchema,
|
||||
type MaybePromise,
|
||||
} from "bknd";
|
||||
import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils";
|
||||
import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export type OtpPluginOptions = {
|
||||
/**
|
||||
* Customize code generation. If not provided, a random 6-digit code will be generated.
|
||||
*/
|
||||
generateCode?: (user: Pick<DB["users"], "email">) => string;
|
||||
|
||||
/**
|
||||
* The base path for the API endpoints.
|
||||
* @default "/api/auth/otp"
|
||||
*/
|
||||
apiBasePath?: string;
|
||||
|
||||
/**
|
||||
* The TTL for the OTP tokens in seconds.
|
||||
* @default 600 (10 minutes)
|
||||
*/
|
||||
ttl?: number;
|
||||
|
||||
/**
|
||||
* The name of the OTP entity.
|
||||
* @default "users_otp"
|
||||
*/
|
||||
entity?: string;
|
||||
|
||||
/**
|
||||
* Customize email content. If not provided, a default email will be sent.
|
||||
*/
|
||||
generateEmail?: (
|
||||
otp: OtpFieldSchema,
|
||||
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
|
||||
|
||||
/**
|
||||
* Enable debug mode for error messages.
|
||||
* @default false
|
||||
*/
|
||||
showActualErrors?: boolean;
|
||||
};
|
||||
|
||||
const otpFields = {
|
||||
action: enumm({
|
||||
enum: ["login", "register"],
|
||||
}),
|
||||
code: text().required(),
|
||||
email: text().required(),
|
||||
created_at: datetime(),
|
||||
expires_at: datetime().required(),
|
||||
used_at: datetime(),
|
||||
};
|
||||
|
||||
export type OtpFieldSchema = FieldSchema<typeof otpFields>;
|
||||
|
||||
export function otp({
|
||||
generateCode: _generateCode,
|
||||
apiBasePath = "/api/auth/otp",
|
||||
ttl = 600,
|
||||
entity: entityName = "users_otp",
|
||||
generateEmail: _generateEmail,
|
||||
showActualErrors = false,
|
||||
}: OtpPluginOptions = {}): AppPlugin {
|
||||
return (app: App) => {
|
||||
return {
|
||||
name: "bknd-otp",
|
||||
schema: () =>
|
||||
em(
|
||||
{
|
||||
[entityName]: entity(entityName, otpFields),
|
||||
},
|
||||
({ index }, schema) => {
|
||||
const otp = schema[entityName]!;
|
||||
index(otp).on(["email", "expires_at", "code"]);
|
||||
},
|
||||
),
|
||||
onBuilt: async () => {
|
||||
// @todo: check if email driver is registered
|
||||
const auth = app.module.auth;
|
||||
invariant(auth && auth.enabled === true, "[OTP Plugin]: Auth is not enabled");
|
||||
invariant(app.drivers?.email, "[OTP Plugin]: Email driver is not registered");
|
||||
|
||||
const generateCode =
|
||||
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
|
||||
const generateEmail =
|
||||
_generateEmail ??
|
||||
((otp: OtpFieldSchema) => ({
|
||||
subject: "OTP Code",
|
||||
body: `Your OTP code is: ${otp.code}`,
|
||||
}));
|
||||
const em = app.em.fork();
|
||||
|
||||
const hono = new Hono()
|
||||
.post(
|
||||
"/login",
|
||||
jsc(
|
||||
"json",
|
||||
s.object({
|
||||
email: s.string({ format: "email" }),
|
||||
code: s.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { email, code } = c.req.valid("json");
|
||||
const user = await findUser(app, email);
|
||||
|
||||
if (code) {
|
||||
const otpData = await getValidatedCode(
|
||||
app,
|
||||
entityName,
|
||||
email,
|
||||
code,
|
||||
"login",
|
||||
);
|
||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
||||
|
||||
const jwt = await auth.authenticator.jwt(user);
|
||||
// @ts-expect-error private method
|
||||
return auth.authenticator.respondWithUser(c, { user, token: jwt });
|
||||
} else {
|
||||
const otpData = await generateAndSendCode(
|
||||
app,
|
||||
{ generateCode, generateEmail, ttl, entity: entityName },
|
||||
user,
|
||||
"login",
|
||||
);
|
||||
|
||||
return c.json({ data: otpData }, HttpStatus.CREATED);
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/register",
|
||||
jsc(
|
||||
"json",
|
||||
s.object({
|
||||
email: s.string({ format: "email" }),
|
||||
code: s.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { email, code } = c.req.valid("json");
|
||||
|
||||
// throw if user exists
|
||||
if (!(await threwAsync(findUser(app, email)))) {
|
||||
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (code) {
|
||||
const otpData = await getValidatedCode(
|
||||
app,
|
||||
entityName,
|
||||
email,
|
||||
code,
|
||||
"register",
|
||||
);
|
||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
||||
|
||||
const user = await app.createUser({
|
||||
email,
|
||||
password: randomString(16, true),
|
||||
});
|
||||
|
||||
const jwt = await auth.authenticator.jwt(user);
|
||||
// @ts-expect-error private method
|
||||
return auth.authenticator.respondWithUser(c, { user, token: jwt });
|
||||
} else {
|
||||
const otpData = await generateAndSendCode(
|
||||
app,
|
||||
{ generateCode, generateEmail, ttl, entity: entityName },
|
||||
{ email },
|
||||
"register",
|
||||
);
|
||||
|
||||
return c.json({ data: otpData }, HttpStatus.CREATED);
|
||||
}
|
||||
},
|
||||
)
|
||||
.onError((err) => {
|
||||
if (showActualErrors) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new Exception("Invalid code", HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
app.server.route(apiBasePath, hono);
|
||||
|
||||
// just for now, prevent mutations of the OTP entity
|
||||
registerListeners(app, entityName);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function findUser(app: App, email: string) {
|
||||
const user_entity = app.module.auth.config.entity_name as "users";
|
||||
const { data: user } = await app.em.repo(user_entity).findOne({ email });
|
||||
if (!user) {
|
||||
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function generateAndSendCode(
|
||||
app: App,
|
||||
opts: Required<Pick<OtpPluginOptions, "generateCode" | "generateEmail" | "ttl" | "entity">>,
|
||||
user: Pick<DB["users"], "email">,
|
||||
action: OtpFieldSchema["action"],
|
||||
) {
|
||||
const { generateCode, generateEmail, ttl, entity: entityName } = opts;
|
||||
const newCode = generateCode?.(user);
|
||||
if (!newCode) {
|
||||
throw new Exception("[OTP Plugin]: Failed to generate code");
|
||||
}
|
||||
|
||||
await invalidateAllUserCodes(app, entityName, user.email, ttl);
|
||||
const { data: otpData } = await app.em
|
||||
.fork()
|
||||
.mutator(entityName)
|
||||
.insertOne({
|
||||
code: newCode,
|
||||
email: user.email,
|
||||
action,
|
||||
created_at: new Date(),
|
||||
expires_at: new Date(Date.now() + ttl * 1000),
|
||||
});
|
||||
|
||||
const { subject, body } = await generateEmail(otpData);
|
||||
await app.drivers?.email?.send(user.email, subject, body);
|
||||
|
||||
return otpData;
|
||||
}
|
||||
|
||||
async function getValidatedCode(
|
||||
app: App,
|
||||
entityName: string,
|
||||
email: string,
|
||||
code: string,
|
||||
action: OtpFieldSchema["action"],
|
||||
) {
|
||||
invariant(email, "[OTP Plugin]: Email is required");
|
||||
invariant(code, "[OTP Plugin]: Code is required");
|
||||
const em = app.em.fork();
|
||||
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
|
||||
if (!otpData) {
|
||||
throw new Exception("Invalid code", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (otpData.expires_at < new Date()) {
|
||||
throw new Exception("Code expired", HttpStatus.GONE);
|
||||
}
|
||||
|
||||
return otpData;
|
||||
}
|
||||
|
||||
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
|
||||
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
|
||||
invariant(email, "[OTP Plugin]: Email is required");
|
||||
const em = app.em.fork();
|
||||
await em
|
||||
.mutator(entityName)
|
||||
.updateWhere(
|
||||
{ expires_at: new Date(Date.now() - ttl * 1000) },
|
||||
{ email, used_at: { $isnull: true } },
|
||||
);
|
||||
}
|
||||
|
||||
function registerListeners(app: App, entityName: string) {
|
||||
app.emgr.onAny(
|
||||
async (event) => {
|
||||
let allowed = true;
|
||||
let action = "";
|
||||
if (event instanceof MutatorInsertBefore) {
|
||||
if (event.params.entity.name === entityName) {
|
||||
allowed = false;
|
||||
action = "create";
|
||||
}
|
||||
} else if (event instanceof MutatorDeleteBefore) {
|
||||
if (event.params.entity.name === entityName) {
|
||||
allowed = false;
|
||||
action = "delete";
|
||||
}
|
||||
} else if (event instanceof MutatorUpdateBefore) {
|
||||
if (event.params.entity.name === entityName) {
|
||||
allowed = false;
|
||||
action = "update";
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new Exception(`[OTP Plugin]: Not allowed ${action} OTP codes manually`);
|
||||
}
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-otp",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
||||
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||
export { otp, type OtpPluginOptions } from "./auth/otp.plugin";
|
||||
|
||||
Reference in New Issue
Block a user