mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import {
|
|
type AuthAction,
|
|
AuthPermissions,
|
|
Authenticator,
|
|
type ProfileExchange,
|
|
Role,
|
|
type Strategy
|
|
} from "auth";
|
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
|
import { type DB, Exception, type PrimaryFieldType } from "core";
|
|
import { type Static, secureRandomString, transformObject } from "core/utils";
|
|
import type { Entity, EntityManager } from "data";
|
|
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
|
import { pick } from "lodash-es";
|
|
import { Module } from "modules/Module";
|
|
import { AuthController } from "./api/AuthController";
|
|
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
|
|
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
|
declare module "core" {
|
|
interface DB {
|
|
users: { id: PrimaryFieldType } & UserFieldSchema;
|
|
}
|
|
}
|
|
|
|
type AuthSchema = Static<typeof authConfigSchema>;
|
|
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
|
|
|
export class AppAuth extends Module<typeof authConfigSchema> {
|
|
private _authenticator?: Authenticator;
|
|
cache: Record<string, any> = {};
|
|
_controller!: AuthController;
|
|
|
|
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
|
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
|
|
|
if (!from.enabled && to.enabled) {
|
|
if (to.jwt.secret === defaultSecret) {
|
|
console.warn("No JWT secret provided, generating a random one");
|
|
to.jwt.secret = secureRandomString(64);
|
|
}
|
|
}
|
|
|
|
return to;
|
|
}
|
|
|
|
get enabled() {
|
|
return this.config.enabled;
|
|
}
|
|
|
|
override async build() {
|
|
if (!this.enabled) {
|
|
this.setBuilt();
|
|
return;
|
|
}
|
|
|
|
// register roles
|
|
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
|
//console.log("role", role, name);
|
|
return Role.create({ name, ...role });
|
|
});
|
|
this.ctx.guard.setRoles(Object.values(roles));
|
|
this.ctx.guard.setConfig(this.config.guard ?? {});
|
|
|
|
// build strategies
|
|
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
|
|
try {
|
|
return new STRATEGIES[strategy.type].cls(strategy.config as any);
|
|
} catch (e) {
|
|
throw new Error(
|
|
`Could not build strategy ${String(
|
|
name
|
|
)} with config ${JSON.stringify(strategy.config)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
|
jwt: this.config.jwt,
|
|
cookie: this.config.cookie
|
|
});
|
|
|
|
this.registerEntities();
|
|
super.setBuilt();
|
|
|
|
this._controller = new AuthController(this);
|
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
|
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
|
|
}
|
|
|
|
get controller(): AuthController {
|
|
if (!this.isBuilt()) {
|
|
throw new Error("Can't access controller, AppAuth not built yet");
|
|
}
|
|
|
|
return this._controller;
|
|
}
|
|
|
|
getSchema() {
|
|
return authConfigSchema;
|
|
}
|
|
|
|
get authenticator(): Authenticator {
|
|
this.throwIfNotBuilt();
|
|
return this._authenticator!;
|
|
}
|
|
|
|
get em(): EntityManager {
|
|
return this.ctx.em as any;
|
|
}
|
|
|
|
private async resolveUser(
|
|
action: AuthAction,
|
|
strategy: Strategy,
|
|
identifier: string,
|
|
profile: ProfileExchange
|
|
): Promise<any> {
|
|
/*console.log("***** AppAuth:resolveUser", {
|
|
action,
|
|
strategy: strategy.getName(),
|
|
identifier,
|
|
profile
|
|
});*/
|
|
if (!this.config.allow_register && action === "register") {
|
|
throw new Exception("Registration is not allowed", 403);
|
|
}
|
|
|
|
const fields = this.getUsersEntity()
|
|
.getFillableFields("create")
|
|
.map((f) => f.name);
|
|
const filteredProfile = Object.fromEntries(
|
|
Object.entries(profile).filter(([key]) => fields.includes(key))
|
|
);
|
|
|
|
switch (action) {
|
|
case "login":
|
|
return this.login(strategy, identifier, filteredProfile);
|
|
case "register":
|
|
return this.register(strategy, identifier, filteredProfile);
|
|
}
|
|
}
|
|
|
|
private filterUserData(user: any) {
|
|
/*console.log(
|
|
"--filterUserData",
|
|
user,
|
|
this.config.jwt.fields,
|
|
pick(user, this.config.jwt.fields)
|
|
);*/
|
|
return pick(user, this.config.jwt.fields);
|
|
}
|
|
|
|
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
|
/*console.log("--- trying to login", {
|
|
strategy: strategy.getName(),
|
|
identifier,
|
|
profile
|
|
});*/
|
|
if (!("email" in profile)) {
|
|
throw new Exception("Profile must have email");
|
|
}
|
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
throw new Exception("Identifier must be a string");
|
|
}
|
|
|
|
const users = this.getUsersEntity();
|
|
this.toggleStrategyValueVisibility(true);
|
|
const result = await this.em
|
|
.repo(users as unknown as "users")
|
|
.findOne({ email: profile.email! });
|
|
this.toggleStrategyValueVisibility(false);
|
|
if (!result.data) {
|
|
throw new Exception("User not found", 404);
|
|
}
|
|
//console.log("---login data", result.data, result);
|
|
|
|
// compare strategy and identifier
|
|
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
|
if (result.data.strategy !== strategy.getName()) {
|
|
//console.log("!!! User registered with different strategy");
|
|
throw new Exception("User registered with different strategy");
|
|
}
|
|
|
|
//console.log("identifier comparison", result.data.strategy_value, identifier);
|
|
if (result.data.strategy_value !== identifier) {
|
|
//console.log("!!! Invalid credentials");
|
|
throw new Exception("Invalid credentials");
|
|
}
|
|
|
|
return this.filterUserData(result.data);
|
|
}
|
|
|
|
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
|
if (!("email" in profile)) {
|
|
throw new Exception("Profile must have an email");
|
|
}
|
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
throw new Exception("Identifier must be a string");
|
|
}
|
|
|
|
const users = this.getUsersEntity();
|
|
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
|
|
if (data) {
|
|
throw new Exception("User already exists");
|
|
}
|
|
|
|
const payload: any = {
|
|
...profile,
|
|
strategy: strategy.getName(),
|
|
strategy_value: identifier
|
|
};
|
|
|
|
const mutator = this.em.mutator(users);
|
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
|
this.toggleStrategyValueVisibility(true);
|
|
const createResult = await mutator.insertOne(payload);
|
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
|
this.toggleStrategyValueVisibility(false);
|
|
if (!createResult.data) {
|
|
throw new Error("Could not create user");
|
|
}
|
|
|
|
return this.filterUserData(createResult.data);
|
|
}
|
|
|
|
private toggleStrategyValueVisibility(visible: boolean) {
|
|
const toggle = (name: string, visible: boolean) => {
|
|
const field = this.getUsersEntity().field(name)!;
|
|
|
|
if (visible) {
|
|
field.config.hidden = false;
|
|
field.config.fillable = true;
|
|
} else {
|
|
// reset to normal
|
|
const template = AppAuth.usersFields.strategy_value.config;
|
|
field.config.hidden = template.hidden;
|
|
field.config.fillable = template.fillable;
|
|
}
|
|
};
|
|
|
|
toggle("strategy_value", visible);
|
|
toggle("strategy", visible);
|
|
|
|
// @todo: think about a PasswordField that automatically hashes on save?
|
|
}
|
|
|
|
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
|
|
const entity_name = this.config.entity_name;
|
|
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
|
return entity(entity_name as "users", AppAuth.usersFields, undefined, "system");
|
|
}
|
|
|
|
return this.em.entity(entity_name) as any;
|
|
}
|
|
|
|
static usersFields = {
|
|
email: text().required(),
|
|
strategy: text({
|
|
fillable: ["create"],
|
|
hidden: ["update", "form"]
|
|
}).required(),
|
|
strategy_value: text({
|
|
fillable: ["create"],
|
|
hidden: ["read", "table", "update", "form"]
|
|
}).required(),
|
|
role: text()
|
|
};
|
|
|
|
registerEntities() {
|
|
const users = this.getUsersEntity(true);
|
|
this.ensureSchema(
|
|
em(
|
|
{
|
|
[users.name as "users"]: users
|
|
},
|
|
({ index }, { users }) => {
|
|
index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
|
|
}
|
|
)
|
|
);
|
|
|
|
try {
|
|
const roles = Object.keys(this.config.roles ?? {});
|
|
this.replaceEntityField(users, "role", enumm({ enum: roles }));
|
|
} catch (e) {}
|
|
|
|
try {
|
|
const strategies = Object.keys(this.config.strategies ?? {});
|
|
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
|
} catch (e) {}
|
|
}
|
|
|
|
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
|
if (!this.enabled) {
|
|
throw new Error("Cannot create user, auth not enabled");
|
|
}
|
|
|
|
const strategy = "password";
|
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
|
const strategy_value = await pw.hash(password);
|
|
const mutator = this.em.mutator(this.config.entity_name as "users");
|
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
|
const { data: created } = await mutator.insertOne({
|
|
...(additional as any),
|
|
email,
|
|
strategy,
|
|
strategy_value
|
|
});
|
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
|
return created;
|
|
}
|
|
|
|
override toJSON(secrets?: boolean): AppAuthSchema {
|
|
if (!this.config.enabled) {
|
|
return this.configDefault;
|
|
}
|
|
|
|
return {
|
|
...this.config,
|
|
...this.authenticator.toJSON(secrets)
|
|
};
|
|
}
|
|
}
|