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, 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 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"; const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest); 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", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.5.0-rc6", "version": "0.5.0-rc13",
"scripts": { "scripts": {
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"dev": "vite", "dev": "vite",
@@ -18,7 +18,7 @@
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"cli": "LOCAL=1 bun src/cli/index.ts", "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", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ export class AuthController extends Controller {
} }
override getController() { override getController() {
const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
const strategies = this.auth.authenticator.getStrategies(); const strategies = this.auth.authenticator.getStrategies();
@@ -19,7 +20,7 @@ export class AuthController extends Controller {
hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
} }
hono.get("/me", async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() }); return c.json({ user: await this.auth.authenticator.getUser() });
} }
@@ -27,7 +28,7 @@ export class AuthController extends Controller {
return c.json({ user: null }, 403); return c.json({ user: null }, 403);
}); });
hono.get("/logout", async (c) => { hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c); await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) { if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true }); 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 { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from "hono/utils/cookie";
import { omit } from "lodash-es"; import type { ServerEnv } from "modules/Module";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; 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>> { export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies; private readonly strategies: Strategies;
private readonly config: AuthConfig; private readonly config: AuthConfig;
private _user: SafeUser | undefined; private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver; private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -134,16 +140,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
isUserLoggedIn(): boolean { isUserLoggedIn(): boolean {
return this._user !== undefined; return this._claims !== undefined;
} }
getUser() { getUser(): SafeUser | undefined {
return this._user; if (!this._claims) return;
const { iat, exp, iss, ...user } = this._claims;
return user;
} }
// @todo: determine what to do exactly
resetUser() { resetUser() {
this._user = undefined; this._claims = undefined;
} }
strategy< 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> { async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"]; const prohibited = ["password"];
for (const prop of prohibited) { 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; return true;
} catch (e) { } catch (e) {
this.resetUser(); 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; const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); 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 successPath = this.config.cookie.pathSuccess ?? "/";
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) { if ("token" in data) {
await this.setAuthCookie(c, data.token); await this.setAuthCookie(c, data.token);
// can't navigate to "/" doesn't work on nextjs // can't navigate to "/" doesn't work on nextjs
console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl); return c.redirect(successUrl);
} }
@@ -294,6 +305,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
await addFlashMessage(c, message, "error"); await addFlashMessage(c, message, "error");
console.log("auth failed, redirecting to", referer);
return c.redirect(referer); return c.redirect(referer);
} }
@@ -309,7 +321,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
if (token) { if (token) {
await this.verify(token); await this.verify(token);
return this._user; return this.getUser();
} }
return undefined; return undefined;

View File

@@ -98,12 +98,16 @@ export class Guard {
if (this.user && typeof this.user.role === "string") { if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role); const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) { if (role) {
debug && console.log("guard: role found", this.user.role); debug && console.log("guard: role found", [this.user.role]);
return 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(); 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 type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Module"; import type { ServerEnv } from "modules/Module";
@@ -8,23 +9,31 @@ function getPath(reqOrCtx: Request | Context) {
return new URL(req.url).pathname; return new URL(req.url).pathname;
} }
export function shouldSkipAuth(req: Request) { export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
const skip = getPath(req).startsWith(config.server.assets_path); if (c.get("auth_skip")) return true;
if (skip) {
//console.log("skip auth for", req.url); const req = c.req.raw;
} if (!skip) return false;
return skip;
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 // make sure to only register once
if (c.get("auth_registered")) { if (c.get("auth_registered")) {
throw new Error(`auth middleware already registered for ${getPath(c)}`); throw new Error(`auth middleware already registered for ${getPath(c)}`);
} }
c.set("auth_registered", true); c.set("auth_registered", true);
const skipped = shouldSkipAuth(c.req.raw);
const app = c.get("app"); const app = c.get("app");
const skipped = shouldSkip(c, options?.skip) || !app.module.auth.enabled;
const guard = app.modules.ctx().guard; const guard = app.modules.ctx().guard;
const authenticator = app.module.auth.authenticator; const authenticator = app.module.auth.authenticator;
@@ -35,24 +44,36 @@ export const auth = createMiddleware<ServerEnv>(async (c, next) => {
guard.setUserContext(undefined); guard.setUserContext(undefined);
} else { } else {
guard.setUserContext(await authenticator.resolveAuthFromRequest(c)); guard.setUserContext(await authenticator.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
// renew cookie if applicable
authenticator.requestCookieRefresh(c);
} }
} }
} }
await next(); await next();
if (!skipped) {
// renew cookie if applicable
authenticator.requestCookieRefresh(c);
}
// release // release
guard.setUserContext(undefined); guard.setUserContext(undefined);
authenticator.resetUser(); authenticator?.resetUser();
c.set("auth_resolved", false); 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) => { createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app"); const app = c.get("app");
//console.log("skip?", c.get("auth_skip"));
// in tests, app is not defined // in tests, app is not defined
if (!c.get("auth_registered")) { if (!c.get("auth_registered")) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
@@ -61,11 +82,22 @@ export const permission = (...permissions: Permission[]) =>
} else { } else {
console.warn(msg); console.warn(msg);
} }
} else if (!shouldSkipAuth(c.req.raw)) { } else if (!c.get("auth_skip")) {
const p = Array.isArray(permissions) ? permissions : [permissions];
const guard = app.modules.ctx().guard; const guard = app.modules.ctx().guard;
for (const permission of p) { const permissions = Array.isArray(permission) ? permission : [permission];
guard.throwUnlessGranted(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 path from "node:path";
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; 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) { 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 }) { 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"; const flash_key = "__bknd_flash";
export type FlashMessageType = "error" | "warning" | "success" | "info"; export type FlashMessageType = "error" | "warning" | "success" | "info";
export async function addFlashMessage( export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
c: Context, if (c.req.header("Accept")?.includes("text/html")) {
message: string,
type: FlashMessageType = "info"
) {
setCookie(c, flash_key, JSON.stringify({ type, message }), { setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/" path: "/"
}); });
}
} }
function getCookieValue(name) { 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; 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() { override getController() {
const hono = this.create(); const { permission, auth } = this.middlewares;
const { permission } = this.middlewares; const hono = this.create().use(auth());
const definedEntities = this.em.entities.map((e) => e.name); const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt) .Decode(Number.parseInt)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB