reworked authentication and permission handling

This commit is contained in:
dswbx
2025-01-11 15:27:58 +01:00
parent 5823c2d245
commit bd4bc14282
20 changed files with 190 additions and 101 deletions

View File

@@ -1,4 +1,4 @@
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/app/src/ui/assets/poster.png)
![bknd](docs/_assets/poster.png)
bknd simplifies app development by providing fully functional backend for data management,
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can

View File

@@ -111,6 +111,9 @@ const result = await esbuild.build({
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
// copy assets to static
await $`cp -r src/ui/assets/* dist/static/assets`;
}
/**

View File

@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.5.0-rc6",
"version": "0.5.0-rc13",
"scripts": {
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"dev": "vite",
@@ -18,7 +18,7 @@
"updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run build:all"
"prepublishOnly": "bun run test && bun run build:all"
},
"license": "FSL-1.1-MIT",
"dependencies": {

View File

@@ -1,5 +1,8 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { auth } from "auth/middlewares";
import { config } from "core";
import { Event } from "core/events";
import { patternMatch } from "core/utils";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import {
type InitialModuleConfigs,
@@ -71,6 +74,9 @@ export class App {
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.get("/favicon.ico", (c) =>
c.redirect(config.server.assets_path + "/favicon.ico")
);
server.use(async (c, next) => {
c.set("app", this);
await next();
@@ -159,7 +165,7 @@ export class App {
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.adminController = new AdminController(this, config);
this.modules.server.route("/", this.adminController.getController());
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
return this;
}

View File

@@ -91,10 +91,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._controller;
}
override onServerInit(hono: Hono<any>) {
hono.use(auth);
}
getSchema() {
return authConfigSchema;
}

View File

@@ -11,6 +11,7 @@ export class AuthController extends Controller {
}
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
@@ -19,7 +20,7 @@ export class AuthController extends Controller {
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
}
hono.get("/me", async (c) => {
hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() });
}
@@ -27,7 +28,7 @@ export class AuthController extends Controller {
return c.json({ user: null }, 403);
});
hono.get("/logout", async (c) => {
hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });

View File

@@ -13,7 +13,7 @@ import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import { omit } from "lodash-es";
import type { ServerEnv } from "modules/Module";
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
@@ -101,7 +101,13 @@ export type AuthUserResolver = (
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private _user: SafeUser | undefined;
private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -134,16 +140,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
isUserLoggedIn(): boolean {
return this._user !== undefined;
return this._claims !== undefined;
}
getUser() {
return this._user;
getUser(): SafeUser | undefined {
if (!this._claims) return;
const { iat, exp, iss, ...user } = this._claims;
return user;
}
// @todo: determine what to do exactly
resetUser() {
this._user = undefined;
this._claims = undefined;
}
strategy<
@@ -157,6 +165,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
// @todo: add jwt tests
async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"];
for (const prop of prohibited) {
@@ -203,7 +212,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
this._claims = payload as any;
return true;
} catch (e) {
this.resetUser();
@@ -249,7 +258,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
private async setAuthCookie(c: Context, token: string) {
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
@@ -281,10 +290,12 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
const successPath = this.config.cookie.pathSuccess ?? "/";
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) {
await this.setAuthCookie(c, data.token);
// can't navigate to "/" doesn't work on nextjs
console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl);
}
@@ -294,6 +305,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
await addFlashMessage(c, message, "error");
console.log("auth failed, redirecting to", referer);
return c.redirect(referer);
}
@@ -309,7 +321,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
if (token) {
await this.verify(token);
return this._user;
return this.getUser();
}
return undefined;

View File

@@ -98,12 +98,16 @@ export class Guard {
if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) {
debug && console.log("guard: role found", this.user.role);
debug && console.log("guard: role found", [this.user.role]);
return role;
}
}
debug && console.log("guard: role not found", this.user, this.user?.role);
debug &&
console.log("guard: role not found", {
user: this.user,
role: this.user?.role
});
return this.getDefaultRole();
}

View File

@@ -1,4 +1,5 @@
import { type Permission, config } from "core";
import type { Permission } from "core";
import { patternMatch } from "core/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Module";
@@ -8,23 +9,31 @@ function getPath(reqOrCtx: Request | Context) {
return new URL(req.url).pathname;
}
export function shouldSkipAuth(req: Request) {
const skip = getPath(req).startsWith(config.server.assets_path);
if (skip) {
//console.log("skip auth for", req.url);
}
return skip;
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
if (c.get("auth_skip")) return true;
const req = c.req.raw;
if (!skip) return false;
const path = getPath(req);
const result = skip.some((s) => patternMatch(path, s));
c.set("auth_skip", result);
return result;
}
export const auth = createMiddleware<ServerEnv>(async (c, next) => {
export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
createMiddleware<ServerEnv>(async (c, next) => {
// make sure to only register once
if (c.get("auth_registered")) {
throw new Error(`auth middleware already registered for ${getPath(c)}`);
}
c.set("auth_registered", true);
const skipped = shouldSkipAuth(c.req.raw);
const app = c.get("app");
const skipped = shouldSkip(c, options?.skip) || !app.module.auth.enabled;
const guard = app.modules.ctx().guard;
const authenticator = app.module.auth.authenticator;
@@ -35,24 +44,36 @@ export const auth = createMiddleware<ServerEnv>(async (c, next) => {
guard.setUserContext(undefined);
} else {
guard.setUserContext(await authenticator.resolveAuthFromRequest(c));
// renew cookie if applicable
authenticator.requestCookieRefresh(c);
c.set("auth_resolved", true);
}
}
}
await next();
if (!skipped) {
// renew cookie if applicable
authenticator.requestCookieRefresh(c);
}
// release
guard.setUserContext(undefined);
authenticator.resetUser();
authenticator?.resetUser();
c.set("auth_resolved", false);
});
});
export const permission = (...permissions: Permission[]) =>
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
}
) =>
// @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
//console.log("skip?", c.get("auth_skip"));
// in tests, app is not defined
if (!c.get("auth_registered")) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
@@ -61,11 +82,22 @@ export const permission = (...permissions: Permission[]) =>
} else {
console.warn(msg);
}
} else if (!shouldSkipAuth(c.req.raw)) {
const p = Array.isArray(permissions) ? permissions : [permissions];
} else if (!c.get("auth_skip")) {
const guard = app.modules.ctx().guard;
for (const permission of p) {
guard.throwUnlessGranted(permission);
const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p));
}
}

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import type { Config } from "@libsql/client/node";
import { config } from "core";
import type { MiddlewareHandler } from "hono";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
}
export async function attachServeStatic(app: any, platform: Platform) {
app.module.server.client.get("/*", await serveStatic(platform));
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
}
export async function startServer(server: Platform, app: any, options: { port: number }) {

View File

@@ -4,14 +4,12 @@ import { setCookie } from "hono/cookie";
const flash_key = "__bknd_flash";
export type FlashMessageType = "error" | "warning" | "success" | "info";
export async function addFlashMessage(
c: Context,
message: string,
type: FlashMessageType = "info"
) {
export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
if (c.req.header("Accept")?.includes("text/html")) {
setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/"
});
}
}
function getCookieValue(name) {

View File

@@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record<string, any>
return key in vars ? vars[key] : match;
});
}
export function patternMatch(target: string, pattern: RegExp | string): boolean {
if (pattern instanceof RegExp) {
return pattern.test(target);
} else if (typeof pattern === "string" && pattern.startsWith("/")) {
return new RegExp(pattern).test(target);
} else if (typeof pattern === "string") {
return target.startsWith(pattern);
}
return false;
}

View File

@@ -69,8 +69,9 @@ export class DataController extends Controller {
}
override getController() {
const hono = this.create();
const { permission } = this.middlewares;
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)

View File

@@ -27,8 +27,8 @@ export class MediaController extends Controller {
override getController() {
// @todo: multiple providers?
// @todo: implement range requests
const hono = this.create();
const { auth } = this.middlewares;
const hono = this.create().use(auth());
// get files list (temporary)
hono.get("/files", async (c) => {

View File

@@ -2,11 +2,13 @@ import { auth, permission } from "auth/middlewares";
import { Hono } from "hono";
import type { ServerEnv } from "modules/Module";
export class Controller {
protected middlewares = {
const middlewares = {
auth,
permission
};
} as const;
export class Controller {
protected middlewares = middlewares;
protected create(): Hono<ServerEnv> {
return Controller.createServer();

View File

@@ -14,6 +14,8 @@ export type ServerEnv = {
auth_resolved: boolean;
// to only register once
auth_registered: boolean;
// whether or not to bypass auth
auth_skip: boolean;
html?: string;
};
};

View File

@@ -47,7 +47,13 @@ export class AdminController extends Controller {
}
override getController() {
const hono = this.create().basePath(this.withBasePath());
const { auth: authMiddleware, permission } = this.middlewares;
const hono = this.create().use(
authMiddleware({
skip: [/favicon\.ico$/]
})
);
const auth = this.app.module.auth;
const configs = this.app.modules.configs();
// if auth is not enabled, authenticator is undefined
@@ -78,16 +84,17 @@ export class AdminController extends Controller {
});
if (auth_enabled) {
hono.get(authRoutes.login, async (c) => {
if (
this.app.module.auth.authenticator?.isUserLoggedIn() &&
this.ctx.guard.granted(SystemPermissions.accessAdmin)
) {
hono.get(
authRoutes.login,
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
onGranted: async (c) => {
return c.redirect(authRoutes.success);
}
}),
async (c) => {
return c.html(c.get("html")!);
});
}
);
hono.get(authRoutes.logout, async (c) => {
await auth.authenticator?.logout(c);
@@ -95,14 +102,25 @@ export class AdminController extends Controller {
});
}
hono.get("*", async (c) => {
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
hono.get(
"/*",
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
console.log("redirecting");
return c.redirect(authRoutes.login);
}
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
}
}),
async (c) => {
return c.html(c.get("html")!);
});
}
);
return hono;
}
@@ -150,6 +168,7 @@ export class AdminController extends Controller {
}
const theme = configs.server.admin.color_scheme ?? "light";
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
return (
<Fragment>
@@ -162,6 +181,7 @@ export class AdminController extends Controller {
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title>
{isProd ? (
<Fragment>

View File

@@ -38,8 +38,8 @@ export class SystemController extends Controller {
}
private registerConfigController(client: Hono<any>): void {
const hono = this.create();
const { permission } = this.middlewares;
const hono = this.create();
hono.use(permission(SystemPermissions.configRead));
@@ -202,8 +202,8 @@ export class SystemController extends Controller {
}
override getController() {
const hono = this.create();
const { permission } = this.middlewares;
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
this.registerConfigController(hono);

View File

@@ -10,7 +10,7 @@ export default defineConfig({
__isDev: "1"
},
clearScreen: false,
publicDir: "./src/admin/assets",
publicDir: "./src/ui/assets",
server: {
host: true,
port: 28623,

View File

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB