mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Merge pull request #320 from bknd-io/feat/auth-role-and-password
feat: add `default_role_register` and password `minLength` config
This commit is contained in:
13
app/__test__/auth/strategies/PasswordStrategy.spec.ts
Normal file
13
app/__test__/auth/strategies/PasswordStrategy.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
describe("PasswordStrategy", () => {
|
||||||
|
it("should enforce provided minimum length", async () => {
|
||||||
|
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
|
||||||
|
|
||||||
|
expect(strategy.verify("password")({} as any)).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { App, createApp, type AuthResponse } from "../../src";
|
import { App, createApp, type AuthResponse } from "../../src";
|
||||||
import { auth } from "../../src/modules/middlewares";
|
import { auth } from "../../src/modules/middlewares";
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
import {
|
||||||
|
mergeObject,
|
||||||
|
randomString,
|
||||||
|
secureRandomString,
|
||||||
|
withDisabledConsole,
|
||||||
|
} from "../../src/core/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -62,12 +68,12 @@ const configs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createAuthApp() {
|
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
config: {
|
config: {
|
||||||
auth: configs.auth,
|
auth: mergeObject(configs.auth, config ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
|||||||
|
|
||||||
return { res, data };
|
return { res, data };
|
||||||
},
|
},
|
||||||
|
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(),
|
||||||
|
body: body(user),
|
||||||
|
})) as Response;
|
||||||
|
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||||
|
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
const res = (await app.server.request("/api/auth/me", {
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -245,4 +261,61 @@ describe("integration auth", () => {
|
|||||||
expect(await $fns.me()).toEqual({ user: null as any });
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should register users with default role", async () => {
|
||||||
|
const app = createAuthApp({ default_role_register: "guest" });
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("guest");
|
||||||
|
|
||||||
|
// throws error if role doesn't exist
|
||||||
|
expect(
|
||||||
|
app.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "doesnt exist",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// takes role if provided
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test2@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("admin");
|
||||||
|
|
||||||
|
// registering with role is not allowed
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.res.ok),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.data.user.role),
|
||||||
|
).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -223,4 +223,32 @@ describe("AppAuth", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default role for registration must be a valid role", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "123456",
|
||||||
|
},
|
||||||
|
allow_register: true,
|
||||||
|
roles: {
|
||||||
|
guest: {
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const auth = app.module.auth;
|
||||||
|
// doesn't allow invalid role
|
||||||
|
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
|
||||||
|
// allows valid role
|
||||||
|
await auth.schema().patch("default_role_register", "guest");
|
||||||
|
expect(auth.toJSON().default_role_register).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
to.strategies!.password!.enabled = true;
|
to.strategies!.password!.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.default_role_register && to.default_role_register?.length > 0) {
|
||||||
|
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
|
||||||
|
|
||||||
|
if (!valid_to_role) {
|
||||||
|
const msg = `Default role for registration not found: ${to.default_role_register}`;
|
||||||
|
// if changing to a new value
|
||||||
|
if (from.default_role_register !== to.default_role_register) {
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetting gracefully, since role doesn't exist anymore
|
||||||
|
$console.warn(`${msg}, resetting to undefined`);
|
||||||
|
to.default_role_register = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +98,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||||
jwt: this.config.jwt,
|
jwt: this.config.jwt,
|
||||||
cookie: this.config.cookie,
|
cookie: this.config.cookie,
|
||||||
|
default_role_register: this.config.default_role_register,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerEntities();
|
this.registerEntities();
|
||||||
@@ -171,10 +188,20 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
async createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
...additional
|
||||||
|
}: CreateUserPayload): Promise<DB["users"]> {
|
||||||
if (!this.enabled) {
|
if (!this.enabled) {
|
||||||
throw new Error("Cannot create user, auth not enabled");
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
}
|
}
|
||||||
|
if (role) {
|
||||||
|
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
|
||||||
|
throw new Error(`Role "${role}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const strategy = "password" as const;
|
const strategy = "password" as const;
|
||||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
@@ -183,6 +210,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
const { data: created } = await mutator.insertOne({
|
const { data: created } = await mutator.insertOne({
|
||||||
...(additional as any),
|
...(additional as any),
|
||||||
|
role: role || this.config.default_role_register || undefined,
|
||||||
email,
|
email,
|
||||||
strategy,
|
strategy,
|
||||||
strategy_value,
|
strategy_value,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
InvalidSchemaError,
|
InvalidSchemaError,
|
||||||
transformObject,
|
transformObject,
|
||||||
mcpTool,
|
mcpTool,
|
||||||
|
$console,
|
||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
|
||||||
@@ -210,7 +211,7 @@ export class AuthController extends Controller {
|
|||||||
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||||
|
|
||||||
const getUser = async (params: { id?: string | number; email?: string }) => {
|
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||||
let user: DB["users"] | undefined = undefined;
|
let user: DB["users"] | undefined;
|
||||||
if (params.id) {
|
if (params.id) {
|
||||||
const { data } = await this.userRepo.findId(params.id);
|
const { data } = await this.userRepo.findId(params.id);
|
||||||
user = data;
|
user = data;
|
||||||
@@ -225,26 +226,33 @@ export class AuthController extends Controller {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roles = Object.keys(this.auth.config.roles ?? {});
|
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||||
mcp.tool(
|
try {
|
||||||
"auth_user_create",
|
const actions = this.auth.authenticator.strategy("password").getActions();
|
||||||
{
|
if (actions.create) {
|
||||||
description: "Create a new user",
|
const schema = actions.create.schema;
|
||||||
inputSchema: s.object({
|
mcp.tool(
|
||||||
email: s.string({ format: "email" }),
|
"auth_user_create",
|
||||||
password: s.string({ minLength: 8 }),
|
{
|
||||||
role: s
|
description: "Create a new user",
|
||||||
.string({
|
inputSchema: s.object({
|
||||||
enum: roles.length > 0 ? roles : undefined,
|
...schema.properties,
|
||||||
})
|
role: s
|
||||||
.optional(),
|
.string({
|
||||||
}),
|
enum: roles.length > 0 ? roles : undefined,
|
||||||
},
|
})
|
||||||
async (params, c) => {
|
.optional(),
|
||||||
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
|
||||||
|
|
||||||
return c.json(await this.auth.createUser(params));
|
return c.json(await this.auth.createUser(params));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
$console.warn("error creating auth_user_create tool", e);
|
||||||
|
}
|
||||||
|
|
||||||
mcp.tool(
|
mcp.tool(
|
||||||
"auth_user_token",
|
"auth_user_token",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const authConfigSchema = $object(
|
|||||||
basepath: s.string({ default: "/api/auth" }),
|
basepath: s.string({ default: "/api/auth" }),
|
||||||
entity_name: s.string({ default: "users" }),
|
entity_name: s.string({ default: "users" }),
|
||||||
allow_register: s.boolean({ default: true }).optional(),
|
allow_register: s.boolean({ default: true }).optional(),
|
||||||
|
default_role_register: s.string().optional(),
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
strategies: $record(
|
strategies: $record(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject(
|
|||||||
export const authenticatorConfig = s.object({
|
export const authenticatorConfig = s.object({
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
|
default_role_register: s.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||||
@@ -164,9 +165,13 @@ export class Authenticator<
|
|||||||
if (!("strategy_value" in profile)) {
|
if (!("strategy_value" in profile)) {
|
||||||
throw new InvalidConditionsException("Profile must have a strategy value");
|
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||||
}
|
}
|
||||||
|
if ("role" in profile) {
|
||||||
|
throw new InvalidConditionsException("Role cannot be provided during registration");
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userPool.create(strategy.getName(), {
|
const user = await this.userPool.create(strategy.getName(), {
|
||||||
...profile,
|
...profile,
|
||||||
|
role: this.config.default_role_register,
|
||||||
strategy_value: profile.strategy_value,
|
strategy_value: profile.strategy_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const schema = s
|
|||||||
.object({
|
.object({
|
||||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||||
|
minLength: s.number({ default: 8, minimum: 1 }).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
format: "email",
|
format: "email",
|
||||||
}),
|
}),
|
||||||
password: s.string({
|
password: s.string({
|
||||||
minLength: 8, // @todo: this should be configurable
|
minLength: this.config.minLength,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,12 +66,21 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
return await bcryptCompare(compare, actual);
|
return await bcryptCompare(compare, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return actual === compare;
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(password: string) {
|
verify(password: string) {
|
||||||
return async (user: User) => {
|
return async (user: User) => {
|
||||||
const compare = await this.compare(user?.strategy_value!, password);
|
if (!user || !user.strategy_value) {
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
|
||||||
|
$console.debug("PasswordStrategy: Invalid password", password);
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const compare = await this.compare(user.strategy_value, password);
|
||||||
if (compare !== true) {
|
if (compare !== true) {
|
||||||
throw new InvalidCredentialsException();
|
throw new InvalidCredentialsException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AuthForm({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Password name="password" required minLength={8} />
|
<Password name="password" required minLength={1} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
|||||||
try {
|
try {
|
||||||
const user_entity = config.entity_name ?? "users";
|
const user_entity = config.entity_name ?? "users";
|
||||||
const entities = _s.config.data.entities ?? {};
|
const entities = _s.config.data.entities ?? {};
|
||||||
console.log("entities", entities, user_entity);
|
|
||||||
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
|
||||||
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (user_fields.length > 0) {
|
if (user_fields.length > 0) {
|
||||||
console.log("user_fields", user_fields);
|
|
||||||
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
_schema.properties.jwt.properties.fields.items.enum = user_fields;
|
||||||
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
_schema.properties.jwt.properties.fields.uniqueItems = true;
|
||||||
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
|
||||||
console.log("_s", _s);
|
const roles = Object.keys(config.roles ?? {});
|
||||||
|
if (roles.length > 0) {
|
||||||
|
_schema.properties.default_role_register.enum = roles;
|
||||||
|
}
|
||||||
|
} catch (_e) {}
|
||||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||||
/* if (_s.permissions) {
|
/* if (_s.permissions) {
|
||||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||||
|
|||||||
Reference in New Issue
Block a user