added cookie to config + fixed config set endpoint

This commit is contained in:
dswbx
2024-11-25 16:57:12 +01:00
parent 824ff40133
commit 16a6a3315d
14 changed files with 114 additions and 47 deletions

View File

@@ -1,8 +1,10 @@
/*import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { decodeJwt, jwtVerify } from "jose"; import { decodeJwt, jwtVerify } from "jose";
import { Authenticator, type User, type UserPool } from "../authenticate/Authenticator"; import { Authenticator, type User, type UserPool } from "../../src/auth";
import { PasswordStrategy } from "../authenticate/strategies/PasswordStrategy"; import { cookieConfig } from "../../src/auth/authenticate/Authenticator";
import * as hash from "../utils/hash";*/ import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy";
import * as hash from "../../src/auth/utils/hash";
import { Default, parse } from "../../src/core/utils";
/*class MemoryUserPool implements UserPool { /*class MemoryUserPool implements UserPool {
constructor(private users: User[] = []) {} constructor(private users: User[] = []) {}
@@ -17,10 +19,14 @@ import * as hash from "../utils/hash";*/
this.users.push(newUser); this.users.push(newUser);
return newUser; return newUser;
} }
} }*/
describe("Authenticator", async () => { describe("Authenticator", async () => {
const userpool = new MemoryUserPool([ test("cookie options", async () => {
console.log("parsed", parse(cookieConfig, undefined));
console.log(Default(cookieConfig, {}));
});
/*const userpool = new MemoryUserPool([
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") }, { id: 1, email: "d", username: "test", password: await hash.sha256("test") },
]); ]);
@@ -37,5 +43,5 @@ describe("Authenticator", async () => {
const { iat, ...decoded } = decodeJwt<any>(token); const { iat, ...decoded } = decodeJwt<any>(token);
expect(decoded).toEqual({ id: 1, email: "d", username: "test" }); expect(decoded).toEqual({ id: 1, email: "d", username: "test" });
expect(await auth.verify(token)).toBe(true); expect(await auth.verify(token)).toBe(true);
}); });*/
});*/ });

View File

@@ -29,8 +29,8 @@
"@codemirror/lang-liquid": "^6.2.1", "@codemirror/lang-liquid": "^6.2.1",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.4", "@hono/typebox-validator": "^0.2.6",
"@hono/zod-validator": "^0.2.2", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
@@ -50,7 +50,7 @@
"codemirror-lang-liquid": "^1.0.0", "codemirror-lang-liquid": "^1.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.4.0",
"hono": "^4.4.12", "hono": "^4.6.12",
"jose": "^5.6.3", "jose": "^5.6.3",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"kysely": "^0.27.4", "kysely": "^0.27.4",
@@ -69,8 +69,8 @@
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.3", "@hono/node-server": "^1.13.7",
"@hono/vite-dev-server": "^0.16.0", "@hono/vite-dev-server": "^0.17.0",
"@tanstack/react-query-devtools": "^5.59.16", "@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3", "@types/diff": "^5.2.3",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

View File

@@ -63,7 +63,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}); });
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
jwt: this.config.jwt jwt: this.config.jwt,
cookie: this.config.cookie
}); });
this.registerEntities(); this.registerEntities();
@@ -115,6 +116,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
identifier, identifier,
profile profile
}); });
if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403);
}
const fields = this.getUsersEntity() const fields = this.getUsersEntity()
.getFillableFields("create") .getFillableFields("create")

View File

@@ -1,4 +1,4 @@
import { jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, Type, objectTransform } from "core/utils"; import { type Static, StringRecord, Type, objectTransform } from "core/utils";
@@ -51,7 +51,9 @@ export const authConfigSchema = Type.Object(
enabled: Type.Boolean({ default: false }), enabled: Type.Boolean({ default: false }),
basepath: Type.String({ default: "/api/auth" }), basepath: Type.String({ default: "/api/auth" }),
entity_name: Type.String({ default: "users" }), entity_name: Type.String({ default: "users" }),
allow_register: Type.Optional(Type.Boolean({ default: true })),
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig,
strategies: Type.Optional( strategies: Type.Optional(
StringRecord(strategiesSchema, { StringRecord(strategiesSchema, {
title: "Strategies", title: "Strategies",

View File

@@ -1,8 +1,17 @@
import { Exception } from "core"; import { Exception } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils"; import {
type Static,
StringEnum,
type TSchema,
Type,
parse,
randomString,
transformObject
} from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import type { CookieOptions } from "hono/utils/cookie";
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose"; import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
type Input = any; // workaround type Input = any; // workaround
@@ -41,6 +50,18 @@ export interface UserPool<Fields = "id" | "email" | "username"> {
create: (user: CreateUser) => Promise<User | undefined>; create: (user: CreateUser) => Promise<User | undefined>;
} }
export const cookieConfig = Type.Partial(
Type.Object({
renew: Type.Boolean({ default: true }),
path: Type.String({ default: "/" }),
sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }),
secure: Type.Boolean({ default: true }),
httpOnly: Type.Boolean({ default: true }),
expires: Type.Number({ default: 168 })
}),
{ default: {}, additionalProperties: false }
);
export const jwtConfig = Type.Object( export const jwtConfig = Type.Object(
{ {
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
@@ -56,7 +77,8 @@ export const jwtConfig = Type.Object(
} }
); );
export const authenticatorConfig = Type.Object({ export const authenticatorConfig = Type.Object({
jwt: jwtConfig jwt: jwtConfig,
cookie: cookieConfig
}); });
type AuthConfig = Static<typeof authenticatorConfig>; type AuthConfig = Static<typeof authenticatorConfig>;
@@ -179,12 +201,12 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return false; return false;
} }
// @todo: CookieOptions not exported from hono private get cookieOptions(): CookieOptions {
private get cookieOptions(): any { const { expires = 168, renew, ...cookieConfig } = this.config.cookie;
return { return {
path: "/", ...cookieConfig,
sameSite: "lax", expires: new Date(Date.now() + expires * 60 * 60 * 1000)
httpOnly: true
}; };
} }
@@ -200,6 +222,16 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return token; return token;
} }
async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) {
console.log("renewing cookie", c.req.url);
const token = await this.getAuthCookie(c);
if (token) {
await this.setAuthCookie(c, token);
}
}
}
private async setAuthCookie(c: Context, token: string) { private async setAuthCookie(c: Context, 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);

View File

@@ -90,7 +90,7 @@ export class DataController implements ClassController {
} }
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
this.ctx.guard.throwUnlessGranted(SystemPermissions.api); this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
await next(); await next();
}); });

View File

@@ -27,7 +27,10 @@ export class SystemApi extends ModuleApi<any> {
value: ModuleConfigs[Module], value: ModuleConfigs[Module],
force?: boolean force?: boolean
) { ) {
return await this.post<any>(["config", "set", module, `?force=${force ? 1 : 0}`], value); return await this.post<any>(
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
value
);
} }
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {

View File

@@ -78,6 +78,13 @@ export const migrations: Migration[] = [
up: async (config, { db }) => { up: async (config, { db }) => {
return config; return config;
} }
},
{
version: 7,
up: async (config, { db }) => {
// automatically adds auth.cookie options
return config;
}
} }
]; ];

View File

@@ -1,7 +1,7 @@
import { Permission } from "core"; import { Permission } from "core";
export const admin = new Permission("system.admin"); export const accessAdmin = new Permission("system.access.admin");
export const api = new Permission("system.api"); export const accessApi = new Permission("system.access.api");
export const configRead = new Permission("system.config.read"); export const configRead = new Permission("system.config.read");
export const configReadSecrets = new Permission("system.config.read.secrets"); export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write"); export const configWrite = new Permission("system.config.write");

View File

@@ -41,6 +41,7 @@ export class AdminController implements ClassController {
getController(): Hono<any> { getController(): Hono<any> {
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
const auth_enabled = configs.auth.enabled; const auth_enabled = configs.auth.enabled;
const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/"); const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/");
const hono = new Hono<{ const hono = new Hono<{
@@ -50,7 +51,7 @@ export class AdminController implements ClassController {
}>().basePath(basepath); }>().basePath(basepath);
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { user: auth.authenticator.getUser() }; const obj = { user: auth.authenticator?.getUser() };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);
if (!html) { if (!html) {
console.warn("Couldn't generate HTML for admin UI"); console.warn("Couldn't generate HTML for admin UI");
@@ -58,13 +59,17 @@ export class AdminController implements ClassController {
return c.notFound() as unknown as void; return c.notFound() as unknown as void;
} }
c.set("html", html); c.set("html", html);
// refresh cookie if needed
await auth.authenticator?.requestCookieRefresh(c);
await next(); await next();
}); });
if (auth_enabled) {
hono.get(authRoutes.login, async (c) => { hono.get(authRoutes.login, async (c) => {
if ( if (
this.app.module.auth.authenticator.isUserLoggedIn() && this.app.module.auth.authenticator?.isUserLoggedIn() &&
this.ctx.guard.granted(SystemPermissions.admin) this.ctx.guard.granted(SystemPermissions.accessAdmin)
) { ) {
return c.redirect(authRoutes.root); return c.redirect(authRoutes.root);
} }
@@ -74,13 +79,14 @@ export class AdminController implements ClassController {
}); });
hono.get(authRoutes.logout, async (c) => { hono.get(authRoutes.logout, async (c) => {
await auth.authenticator.logout(c); await auth.authenticator?.logout(c);
return c.redirect(authRoutes.login); return c.redirect(authRoutes.login);
}); });
}
hono.get("*", async (c) => { hono.get("*", async (c) => {
console.log("admin", c.req.url); console.log("admin", c.req.url);
if (!this.ctx.guard.granted(SystemPermissions.admin)) { if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
return c.redirect(authRoutes.login); return c.redirect(authRoutes.login);
} }
@@ -128,6 +134,7 @@ export class AdminController implements ClassController {
return ( return (
<Fragment> <Fragment>
{/* dnd complains otherwise */}
{html`<!doctype html>`} {html`<!doctype html>`}
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}> <html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
<head> <head>

View File

@@ -30,8 +30,12 @@ export function BkndProvider({
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>();
const client = useClient(); const client = useClient();
async function fetchSchema(_includeSecrets: boolean = false) { async function reloadSchema() {
if (withSecrets) return; await fetchSchema(includeSecrets, true);
}
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
if (withSecrets && !force) return;
const { body, res } = await client.api.system.readSchema({ const { body, res } = await client.api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
@@ -80,7 +84,7 @@ export function BkndProvider({
if (!fetched || !schema) return null; if (!fetched || !schema) return null;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ client, setSchema }); const actions = getSchemaActions({ client, setSchema, reloadSchema });
return ( return (
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}> <BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>

View File

@@ -6,11 +6,12 @@ import type { AppQueryClient } from "../utils/AppQueryClient";
export type SchemaActionsProps = { export type SchemaActionsProps = {
client: AppQueryClient; client: AppQueryClient;
setSchema: React.Dispatch<React.SetStateAction<any>>; setSchema: React.Dispatch<React.SetStateAction<any>>;
reloadSchema: () => Promise<void>;
}; };
export type TSchemaActions = ReturnType<typeof getSchemaActions>; export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) {
const api = client.api; const api = client.api;
async function handleConfigUpdate( async function handleConfigUpdate(
@@ -61,6 +62,7 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return { return {
reload: reloadSchema,
set: async <Module extends keyof ModuleConfigs>( set: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
value: ModuleConfigs[Module], value: ModuleConfigs[Module],

View File

@@ -155,8 +155,8 @@ export function Setting<Schema extends TObject = any>({
if (success) { if (success) {
if (options?.reloadOnSave) { if (options?.reloadOnSave) {
window.location.reload(); window.location.reload();
//await actions.reload();
} }
//window.location.reload();
} else { } else {
setSubmitting(false); setSubmitting(false);
} }

BIN
bun.lockb

Binary file not shown.