diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index a479d19f..fffca386 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -39,10 +39,28 @@ describe("MediaApi", () => { // @ts-ignore tests const api = new MediaApi({ token: "token", + token_transport: "header", }); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); + it("should return empty headers if not using `header` transport", () => { + expect( + new MediaApi({ + token_transport: "cookie", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + expect( + new MediaApi({ + token_transport: "none", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + }); + it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 9091fbba..ede12f6a 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -69,7 +69,7 @@ describe("AppAuth", () => { }, body: JSON.stringify({ email: "some@body.com", - password: "123456", + password: "12345678", }), }); enableConsoleLog(); @@ -81,6 +81,65 @@ describe("AppAuth", () => { } }); + test("creates user on register (bcrypt)", async () => { + const auth = new AppAuth( + { + enabled: true, + strategies: { + password: { + type: "password", + config: { + hashing: "bcrypt", + }, + }, + }, + // @ts-ignore + jwt: { + secret: "123456", + }, + }, + ctx, + ); + + await auth.build(); + await ctx.em.schema().sync({ force: true }); + + // expect no users, but the query to pass + const res = await ctx.em.repository("users").findMany(); + expect(res.data.length).toBe(0); + + const app = new AuthController(auth).getController(); + + { + disableConsoleLog(); + const res = await app.request("/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "some@body.com", + password: "12345678", + }), + }); + enableConsoleLog(); + expect(res.status).toBe(200); + + const { data: users } = await ctx.em.repository("users").findMany(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("some@body.com"); + } + + { + // check user in database + const rawUser = await ctx.connection.kysely + .selectFrom("users") + .selectAll() + .executeTakeFirstOrThrow(); + expect(rawUser.strategy_value).toStartWith("$"); + } + }); + test("registers auth middleware for bknd routes only", async () => { const app = createApp({ initialConfig: { diff --git a/app/package.json b/app/package.json index dae26c73..5c5fdca3 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/bknd-io/bknd/issues" }, "scripts": { - "dev": "vite", + "dev": "BKND_CLI_LOG_LEVEL=debug vite", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", @@ -58,6 +58,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 95d87312..e42ac05b 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,20 +1,13 @@ -import { - type AuthAction, - AuthPermissions, - Authenticator, - type ProfileExchange, - Role, - type Strategy, -} from "auth"; +import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; -import { type Static, secureRandomString, transformObject } from "core/utils"; +import { $console, type DB, type PrimaryFieldType } from "core"; +import { 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 { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; -import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; +import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; +import { AppUserPool } from "auth/AppUserPool"; export type UserFieldSchema = FieldSchema; declare module "core" { @@ -23,7 +16,6 @@ declare module "core" { } } -type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { @@ -31,7 +23,7 @@ export class AppAuth extends Module { cache: Record = {}; _controller!: AuthController; - override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; if (!from.enabled && to.enabled) { @@ -80,7 +72,7 @@ export class AppAuth extends Module { } }); - this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { + this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, }); @@ -90,7 +82,7 @@ export class AppAuth extends Module { this._controller = new AuthController(this); this.ctx.server.route(this.config.basepath, this._controller.getController()); - this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); + this.ctx.guard.registerPermissions(AuthPermissions); } isStrategyEnabled(strategy: Strategy | string) { @@ -122,119 +114,6 @@ export class AppAuth extends Module { return this.ctx.em as any; } - private async resolveUser( - action: AuthAction, - strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - 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) { - return pick(user, this.config.jwt.fields); - } - - private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - 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); - } - - // compare strategy and identifier - if (result.data.strategy !== strategy.getName()) { - throw new Exception("User registered with different strategy"); - } - - if (result.data.strategy_value !== identifier) { - 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)) { @@ -287,7 +166,7 @@ export class AppAuth extends Module { throw new Error("Cannot create user, auth not enabled"); } - const strategy = "password"; + const strategy = "password" as const; 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"); @@ -314,8 +193,7 @@ export class AppAuth extends Module { ...this.authenticator.toJSON(secrets), strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), - type: strategy.getType(), - config: strategy.toJSON(secrets), + ...strategy.toJSON(secrets), })), }; } diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts new file mode 100644 index 00000000..23f24d06 --- /dev/null +++ b/app/src/auth/AppUserPool.ts @@ -0,0 +1,83 @@ +import { AppAuth } from "auth/AppAuth"; +import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; +import { $console } from "core"; +import { pick } from "lodash-es"; +import { + InvalidConditionsException, + UnableToCreateUserException, + UserNotFoundException, +} from "auth/errors"; + +export class AppUserPool implements UserPool { + constructor(private appAuth: AppAuth) {} + + get em() { + return this.appAuth.em; + } + + get users() { + return this.appAuth.getUsersEntity(); + } + + async findBy(strategy: string, prop: keyof SafeUser, value: any) { + $console.debug("[AppUserPool:findBy]", { strategy, prop, value }); + this.toggleStrategyValueVisibility(true); + const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy }); + this.toggleStrategyValueVisibility(false); + + if (!result.data) { + $console.debug("[AppUserPool]: User not found"); + throw new UserNotFoundException(); + } + + return result.data; + } + + async create(strategy: string, payload: CreateUser & Partial>) { + $console.debug("[AppUserPool:create]", { strategy, payload }); + if (!("strategy_value" in payload)) { + throw new InvalidConditionsException("Profile must have a strategy_value value"); + } + + const fields = this.users.getSelect(undefined, "create"); + const safeProfile = pick(payload, fields) as any; + const createPayload: Omit = { + ...safeProfile, + strategy, + }; + + const mutator = this.em.mutator(this.users); + mutator.__unstable_toggleSystemEntityCreation(false); + this.toggleStrategyValueVisibility(true); + const createResult = await mutator.insertOne(createPayload); + mutator.__unstable_toggleSystemEntityCreation(true); + this.toggleStrategyValueVisibility(false); + if (!createResult.data) { + throw new UnableToCreateUserException(); + } + + $console.debug("[AppUserPool]: User created", createResult.data); + return createResult.data; + } + + private toggleStrategyValueVisibility(visible: boolean) { + const toggle = (name: string, visible: boolean) => { + const field = this.users.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? + } +} diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index c4f1100c..6632a6f5 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,4 +1,4 @@ -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, @@ -6,6 +6,7 @@ import { type TObject, parse, runtimeSupports, + truncate, } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -14,6 +15,7 @@ import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; import * as tbbox from "@sinclair/typebox"; +import { InvalidConditionsException } from "auth/errors"; const { Type } = tbbox; type Input = any; // workaround @@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const; export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyAction = { schema: S; - preprocess: (input: unknown) => Promise>; + preprocess: (input: Static) => Promise>; }; export type StrategyActions = Partial>; // @todo: add schema to interface to ensure proper inference +// @todo: add tests (e.g. invalid strategy_value) export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; @@ -37,28 +40,22 @@ export interface Strategy { getActions?: () => StrategyActions; } -export type User = { - id: PrimaryFieldType; - email: string; - password: string; - role?: string | null; -}; +export type User = DB["users"]; export type ProfileExchange = { email?: string; - username?: string; - sub?: string; - password?: string; + strategy?: string; + strategy_value?: string; [key: string]: any; }; -export type SafeUser = Omit; +export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; -export interface UserPool { - findBy: (prop: Fields, value: string | number) => Promise; - create: (user: CreateUser) => Promise; +export interface UserPool { + findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise; + create: (strategy: string, user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds @@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({ type AuthConfig = Static; export type AuthAction = "login" | "register"; +export type AuthResolveOptions = { + identifier?: "email" | string; + redirect?: string; + forceJsonResponse?: boolean; +}; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, - identifier: string, profile: ProfileExchange, -) => Promise; + opts?: AuthResolveOptions, +) => Promise; type AuthClaims = SafeUser & { iat: number; iss?: string; @@ -113,32 +115,117 @@ type AuthClaims = SafeUser & { }; export class Authenticator = Record> { - private readonly strategies: Strategies; private readonly config: AuthConfig; - private readonly userResolver: AuthUserResolver; - constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { - this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); - this.strategies = strategies as Strategies; + constructor( + private readonly strategies: Strategies, + private readonly userPool: UserPool, + config?: AuthConfig, + ) { this.config = parse(authenticatorConfig, config ?? {}); } - async resolve( - action: AuthAction, + async resolveLogin( + c: Context, strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - const user = await this.userResolver(action, strategy, identifier, profile); + profile: Partial, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + // @todo: centralize identifier and checks + // @todo: check identifier value (if allowed) + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } - if (user) { - return { - user, - token: await this.jwt(user), - }; + const user = await this.userPool.findBy( + strategy.getName(), + identifier as any, + profile[identifier], + ); + + if (!user.strategy_value) { + throw new InvalidConditionsException("User must have a strategy value"); + } else if (user.strategy !== strategy.getName()) { + throw new InvalidConditionsException("User signed up with a different strategy"); + } + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + async resolveRegister( + c: Context, + strategy: Strategy, + profile: CreateUser, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } + if (!("strategy_value" in profile)) { + throw new InvalidConditionsException("Profile must have a strategy value"); + } + + const user = await this.userPool.create(strategy.getName(), { + ...profile, + strategy_value: profile.strategy_value, + }); + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) { + const successUrl = this.getSafeUrl( + c, + opts?.redirect ?? this.config.cookie.pathSuccess ?? "/", + ); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + return c.json(data); + } + + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } - throw new Error("User could not be resolved"); + throw new Exception("Invalid response"); + } + + private async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) { + $console.error("respondWithError", error); + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + // let the server handle it + throw error; + } + + await addFlashMessage(c, String(error), "error"); + + const referer = opts?.redirect ?? c.req.header("Referer") ?? "/"; + return c.redirect(referer); } getStrategies(): Strategies { @@ -157,7 +244,7 @@ export class Authenticator = Record< } // @todo: add jwt tests - async jwt(_user: Omit): Promise { + async jwt(_user: SafeUser | ProfileExchange): Promise { const user = pick(_user, this.config.jwt.fields); const payload: JWTPayload = { @@ -183,6 +270,14 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } + async safeAuthResponse(_user: User): Promise { + const user = pick(_user, this.config.jwt.fields) as SafeUser; + return { + user, + token: await this.jwt(user), + }; + } + async verify(jwt: string): Promise { try { const payload = await verify( @@ -241,11 +336,13 @@ export class Authenticator = Record< } private async setAuthCookie(c: Context, token: string) { + $console.debug("setting auth cookie", truncate(token)); const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { + $console.debug("deleting auth cookie"); await deleteCookie(c, "auth", this.cookieOptions); } @@ -284,34 +381,6 @@ export class Authenticator = Record< return p; } - async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); - const referer = redirect ?? c.req.header("Referer") ?? successUrl; - - if ("token" in data) { - await this.setAuthCookie(c, data.token); - - if (this.isJsonRequest(c)) { - return c.json(data); - } - - // can't navigate to "/" – doesn't work on nextjs - return c.redirect(successUrl); - } - - if (this.isJsonRequest(c)) { - return c.json(data, 400); - } - - let message = "An error occured"; - if (data instanceof Exception) { - message = data.message; - } - - await addFlashMessage(c, message, "error"); - return c.redirect(referer); - } - // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; @@ -336,13 +405,3 @@ export class Authenticator = Record< }; } } - -export function createStrategyAction( - schema: S, - preprocess: (input: Static) => Promise>, -) { - return { - schema, - preprocess, - } as StrategyAction; -} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 7166d26b..3031d636 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,151 +1,123 @@ -import type { Authenticator, Strategy } from "auth"; -import { tbValidator as tb } from "core"; -import { type Static, StringEnum, parse } from "core/utils"; -import { hash } from "core/utils"; +import { type Authenticator, InvalidCredentialsException, type User } from "auth"; +import { $console, tbValidator as tb } from "core"; +import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; import { Hono } from "hono"; -import { type StrategyActions, createStrategyAction } from "../Authenticator"; +import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "./Strategy"; + const { Type } = tbbox; -type LoginSchema = { username: string; password: string } | { email: string; password: string }; -type RegisterSchema = { email: string; password: string; [key: string]: any }; - -const schema = Type.Object({ - hashing: StringEnum(["plain", "sha256"] as const, { default: "sha256" }), +const schema = StrictObject({ + hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), + rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), }); export type PasswordStrategyOptions = Static; -export class PasswordStrategy implements Strategy { - private options: PasswordStrategyOptions; +export class PasswordStrategy extends Strategy { + constructor(config: Partial = {}) { + super(config as any, "password", "password", "form"); - constructor(options: Partial = {}) { - this.options = parse(schema, options); - } - - async hash(password: string) { - switch (this.options.hashing) { - case "sha256": - return hash.sha256(password); - default: - return password; - } - } - - async login(input: LoginSchema) { - if (!("email" in input) || !("password" in input)) { - throw new Error("Invalid input: Email and password must be provided"); - } - - const hashedPassword = await this.hash(input.password); - return { ...input, password: hashedPassword }; - } - - async register(input: RegisterSchema) { - if (!input.email || !input.password) { - throw new Error("Invalid input: Email and password must be provided"); - } - - return { - ...input, - password: await this.hash(input.password), - }; - } - - getController(authenticator: Authenticator): Hono { - const hono = new Hono(); - - return hono - .post( - "/login", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - try { - const payload = await this.login(body); - const data = await authenticator.resolve( - "login", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - } catch (e) { - return await authenticator.respond(c, e); - } - }, - ) - .post( - "/register", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - const payload = await this.register(body); - const data = await authenticator.resolve( - "register", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - }, - ); - } - - getActions(): StrategyActions { - return { - create: createStrategyAction( - Type.Object({ - email: Type.String({ - pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", - }), - password: Type.String({ - minLength: 8, // @todo: this should be configurable - }), - }), - async ({ password, ...input }) => { - return { - ...input, - strategy_value: await this.hash(password), - }; - }, - ), - }; + this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password), + }; + }); } getSchema() { return schema; } - getType() { - return "password"; + private getPayloadSchema() { + return Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$", + }), + password: Type.String({ + minLength: 8, // @todo: this should be configurable + }), + }); } - getMode() { - return "form" as const; + async hash(password: string) { + switch (this.config.hashing) { + case "sha256": + return hash.sha256(password); + case "bcrypt": { + const salt = await bcryptGenSalt(this.config.rounds ?? 4); + return bcryptHash(password, salt); + } + default: + return password; + } } - getName() { - return "password" as const; + async compare(actual: string, compare: string): Promise { + switch (this.config.hashing) { + case "sha256": { + const compareHashed = await this.hash(compare); + return actual === compareHashed; + } + case "bcrypt": + return await bcryptCompare(compare, actual); + } + + return false; } - toJSON(secrets?: boolean) { - return secrets ? this.options : undefined; + verify(password: string) { + return async (user: User) => { + const compare = await this.compare(user?.strategy_value!, password); + if (compare !== true) { + throw new InvalidCredentialsException(); + } + }; + } + + getController(authenticator: Authenticator): Hono { + const hono = new Hono(); + const redirectQuerySchema = Type.Object({ + redirect: Type.Optional(Type.String()), + }); + const payloadSchema = this.getPayloadSchema(); + + hono.post("/login", tb("query", redirectQuerySchema), async (c) => { + const body = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + const { redirect } = c.req.valid("query"); + + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + }); + + hono.post("/register", tb("query", redirectQuerySchema), async (c) => { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + }); + + return hono; } } diff --git a/app/src/auth/authenticate/strategies/Strategy.ts b/app/src/auth/authenticate/strategies/Strategy.ts new file mode 100644 index 00000000..28fb95cf --- /dev/null +++ b/app/src/auth/authenticate/strategies/Strategy.ts @@ -0,0 +1,63 @@ +import type { + Authenticator, + StrategyAction, + StrategyActionName, + StrategyActions, +} from "../Authenticator"; +import type { Hono } from "hono"; +import type { Static, TSchema } from "@sinclair/typebox"; +import { parse, type TObject } from "core/utils"; + +export type StrategyMode = "form" | "external"; + +export abstract class Strategy { + protected actions: StrategyActions = {}; + + constructor( + protected config: Static, + public type: string, + public name: string, + public mode: StrategyMode, + ) { + // don't worry about typing, it'll throw if invalid + this.config = parse(this.getSchema(), (config ?? {}) as any) as Static; + } + + protected registerAction( + name: StrategyActionName, + schema: S, + preprocess: StrategyAction["preprocess"], + ): void { + this.actions[name] = { + schema, + preprocess, + } as const; + } + + protected abstract getSchema(): Schema; + + abstract getController(auth: Authenticator): Hono; + + getType(): string { + return this.type; + } + + getMode() { + return this.mode; + } + + getName(): string { + return this.name; + } + + toJSON(secrets?: boolean): { type: string; config: Static | {} | undefined } { + return { + type: this.getType(), + config: secrets ? this.config : undefined, + }; + } + + getActions(): StrategyActions { + return this.actions; + } +} diff --git a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts index 7b193c6d..9e9c3b81 100644 --- a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts @@ -1,4 +1,4 @@ -import { type Static, StringEnum } from "core/utils"; +import { type Static, StrictObject, StringEnum } from "core/utils"; import * as tbbox from "@sinclair/typebox"; import type * as oauth from "oauth4webapi"; import { OAuthStrategy } from "./OAuthStrategy"; @@ -9,37 +9,27 @@ type SupportedTypes = "oauth2" | "oidc"; type RequireKeys = Required> & Omit; const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" }); -const oauthSchemaCustom = Type.Object( +const oauthSchemaCustom = StrictObject( { type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: Type.String(), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - token_endpoint_auth_method: StringEnum(["client_secret_basic"]), - }, - { - additionalProperties: false, - }, - ), - as: Type.Object( - { - issuer: Type.String(), - code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), - scopes_supported: Type.Optional(Type.Array(Type.String())), - scope_separator: Type.Optional(Type.String({ default: " " })), - authorization_endpoint: Type.Optional(UrlString), - token_endpoint: Type.Optional(UrlString), - userinfo_endpoint: Type.Optional(UrlString), - }, - { - additionalProperties: false, - }, - ), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + token_endpoint_auth_method: StringEnum(["client_secret_basic"]), + }), + as: StrictObject({ + issuer: Type.String(), + code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), + scopes_supported: Type.Optional(Type.Array(Type.String())), + scope_separator: Type.Optional(Type.String({ default: " " })), + authorization_endpoint: Type.Optional(UrlString), + token_endpoint: Type.Optional(UrlString), + userinfo_endpoint: Type.Optional(UrlString), + }), // @todo: profile mapping }, - { title: "Custom OAuth", additionalProperties: false }, + { title: "Custom OAuth" }, ); type OAuthConfigCustom = Static; @@ -64,6 +54,11 @@ export type IssuerConfig = { }; export class CustomOAuthStrategy extends OAuthStrategy { + constructor(config: OAuthConfigCustom) { + super(config as any); + this.type = "custom_oauth"; + } + override getIssuerConfig(): IssuerConfig { return { ...this.config, profile: async (info) => info } as any; } @@ -72,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy { override getSchema() { return oauthSchemaCustom; } - - override getType() { - return "custom_oauth"; - } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index 481e747a..2055d17c 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,11 +1,12 @@ -import type { AuthAction, Authenticator, Strategy } from "auth"; +import type { AuthAction, Authenticator } from "auth"; import { Exception, isDebug } from "core"; -import { type Static, StringEnum, filterKeys } from "core/utils"; +import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import * as oauth from "oauth4webapi"; import * as issuers from "./issuers"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "auth/authenticate/strategies/Strategy"; const { Type } = tbbox; type ConfiguredIssuers = keyof typeof issuers; @@ -16,15 +17,11 @@ type RequireKeys = Required> & O const schemaProvided = Type.Object( { name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - }, - { - additionalProperties: false, - }, - ), + type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + }), }, { title: "OAuth" }, ); @@ -72,11 +69,13 @@ export class OAuthCallbackException extends Exception { } } -export class OAuthStrategy implements Strategy { - constructor(private _config: OAuthConfig) {} +export class OAuthStrategy extends Strategy { + constructor(config: ProvidedOAuthConfig) { + super(config, "oauth", config.name, "external"); + } - get config() { - return this._config; + getSchema() { + return schemaProvided; } getIssuerConfig(): IssuerConfig { @@ -104,7 +103,7 @@ export class OAuthStrategy implements Strategy { type: info.type, client: { ...info.client, - ...this._config.client, + ...this.config.client, }, }; } @@ -339,20 +338,28 @@ export class OAuthStrategy implements Strategy { state: state.state, }); - try { - const data = await auth.resolve(state.action, this, profile.sub, profile); + const safeProfile = { + email: profile.email, + strategy_value: profile.sub, + } as const; - if (state.mode === "cookie") { - return await auth.respond(c, data, state.redirect); + const verify = async (user) => { + if (user.strategy_value !== profile.sub) { + throw new Exception("Invalid credentials"); } + }; + const opts = { + redirect: state.redirect, + forceJsonResponse: state.mode !== "cookie", + } as const; - return c.json(data); - } catch (e) { - if (state.mode === "cookie") { - return await auth.respond(c, e, state.redirect); - } - - throw e; + switch (state.action) { + case "login": + return auth.resolveLogin(c, this, safeProfile, verify, opts); + case "register": + return auth.resolveRegister(c, this, safeProfile, verify, opts); + default: + throw new Error("Invalid action"); } }); @@ -423,28 +430,15 @@ export class OAuthStrategy implements Strategy { return hono; } - getType() { - return "oauth"; - } - - getMode() { - return "external" as const; - } - - getName() { - return this.config.name; - } - - getSchema() { - return schemaProvided; - } - - toJSON(secrets?: boolean) { + override toJSON(secrets?: boolean) { const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); return { - type: this.getIssuerConfig().type, - ...config, + ...super.toJSON(secrets), + config: { + ...config, + type: this.getIssuerConfig().type, + }, }; } } diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 96e6e1ba..a45c160d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -81,8 +81,12 @@ export class Guard { return this; } - registerPermissions(permissions: Permission[]) { - for (const permission of permissions) { + registerPermissions(permissions: Record); + registerPermissions(permissions: Permission[]); + registerPermissions(permissions: Permission[] | Record) { + const p = Array.isArray(permissions) ? permissions : Object.values(permissions); + + for (const permission of p) { this.registerPermission(permission); } @@ -93,14 +97,13 @@ export class Guard { if (user && typeof user.role === "string") { const role = this.roles?.find((role) => role.name === user?.role); if (role) { - $console.debug("guard: role found", [user.role]); + $console.debug(`guard: role "${user.role}" found`); return role; } } $console.debug("guard: role not found", { - user: user, - role: user?.role, + user, }); return this.getDefaultRole(); } @@ -121,6 +124,10 @@ export class Guard { } const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; + $console.debug("guard: checking permission", { + name, + user: { id: user?.id, role: user?.role }, + }); const exists = this.permissionExists(name); if (!exists) { throw new Error(`Permission ${name} does not exist`); @@ -129,10 +136,10 @@ export class Guard { const role = this.getUserRole(user); if (!role) { - $console.debug("guard: role not found, denying"); + $console.debug("guard: user has no role, denying"); return false; } else if (role.implicit_allow === true) { - $console.debug("guard: role implicit allow, allowing"); + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); return true; } diff --git a/app/src/auth/errors.ts b/app/src/auth/errors.ts index e10266eb..7b725af8 100644 --- a/app/src/auth/errors.ts +++ b/app/src/auth/errors.ts @@ -1,28 +1,66 @@ -import { Exception } from "core"; +import { Exception, isDebug } from "core"; +import { HttpStatus } from "core/utils"; -export class UserExistsException extends Exception { +export class AuthException extends Exception { + getSafeErrorAndCode() { + return { + error: "Invalid credentials", + code: HttpStatus.UNAUTHORIZED, + }; + } + + override toJSON(): any { + if (isDebug()) { + return super.toJSON(); + } + + return { + error: this.getSafeErrorAndCode().error, + type: "AuthException", + }; + } +} + +export class UserExistsException extends AuthException { override name = "UserExistsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor() { super("User already exists"); } } -export class UserNotFoundException extends Exception { +export class UserNotFoundException extends AuthException { override name = "UserNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor() { super("User not found"); } } -export class InvalidCredentialsException extends Exception { +export class InvalidCredentialsException extends AuthException { override name = "InvalidCredentialsException"; - override code = 401; + override code = HttpStatus.UNAUTHORIZED; constructor() { super("Invalid credentials"); } } + +export class UnableToCreateUserException extends AuthException { + override name = "UnableToCreateUserException"; + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + constructor() { + super("Unable to create user"); + } +} + +export class InvalidConditionsException extends AuthException { + override code = HttpStatus.UNPROCESSABLE_ENTITY; + + constructor(message: string) { + super(message ?? "Invalid conditions"); + } +} diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 9173b4bf..4b64c346 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,9 +1,12 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { HttpStatus } from "./utils/reqres"; + export class Exception extends Error { - code = 400; + code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; override name = "Exception"; protected _context = undefined; - constructor(message: string, code?: number) { + constructor(message: string, code?: ContentfulStatusCode) { super(message); if (code) { this.code = code; diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 7f6c0844..0ba600fc 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -98,7 +98,7 @@ export function decodeSearch(str) { export const enum HttpStatus { // Informational responses (100–199) CONTINUE = 100, - SWITCHING_PROTOCOLS = 101, + //SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, @@ -107,8 +107,8 @@ export const enum HttpStatus { CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, - NO_CONTENT = 204, - RESET_CONTENT = 205, + //NO_CONTENT = 204, + //RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, @@ -119,7 +119,7 @@ export const enum HttpStatus { MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, - NOT_MODIFIED = 304, + //NOT_MODIFIED = 304, USE_PROXY = 305, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, @@ -168,3 +168,13 @@ export const enum HttpStatus { NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511, } +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatusEmpty { + // Informational responses (100–199) + SWITCHING_PROTOCOLS = 101, + // Successful responses (200–299) + NO_CONTENT = 204, + RESET_CONTENT = 205, + // Redirection messages (300–399) + NOT_MODIFIED = 304, +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index be115f52..c0523312 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -132,3 +132,8 @@ export function slugify(str: string): string { .replace(/-+/g, "-") // remove consecutive hyphens ); } + +export function truncate(str: string, length = 50, end = "..."): string { + if (str.length <= length) return str; + return str.substring(0, length) + end; +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index 036e3f7b..1267afc7 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -179,6 +179,11 @@ export const StringIdentifier = tb.Type.String({ maxLength: 150, }); +export const StrictObject = ( + properties: T, + options?: tb.ObjectOptions, +): tb.TObject => tb.Type.Object(properties, { ...options, additionalProperties: false }); + SetErrorFunction((error) => { if (error?.schema?.errorMessage) { return error.schema.errorMessage; diff --git a/app/src/data/errors.ts b/app/src/data/errors.ts index 9f159f7c..12912a0b 100644 --- a/app/src/data/errors.ts +++ b/app/src/data/errors.ts @@ -1,26 +1,26 @@ import { Exception } from "core"; -import type { TypeInvalidError } from "core/utils"; +import { HttpStatus, type TypeInvalidError } from "core/utils"; import type { Entity } from "./entities"; import type { Field } from "./fields"; export class UnableToConnectException extends Exception { override name = "UnableToConnectException"; - override code = 500; + override code = HttpStatus.INTERNAL_SERVER_ERROR; } export class InvalidSearchParamsException extends Exception { override name = "InvalidSearchParamsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformRetrieveFailedException extends Exception { override name = "TransformRetrieveFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformPersistFailedException extends Exception { override name = "TransformPersistFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; static invalidType(property: string, expected: string, given: any) { const givenValue = typeof given === "object" ? JSON.stringify(given) : given; @@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception { export class InvalidFieldConfigException extends Exception { override name = "InvalidFieldConfigException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor( field: Field, @@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception { export class EntityNotDefinedException extends Exception { override name = "EntityNotDefinedException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor(entity?: Entity | string) { if (!entity) { @@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception { export class EntityNotFoundException extends Exception { override name = "EntityNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor(entity: Entity | string, id: any) { super( diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index aa53a89c..5d5bd351 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,6 +1,6 @@ import { $console, type PrimaryFieldType } from "core"; -import { type Entity, type EntityManager } from "data"; -import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; +import type { Entity, EntityManager } from "data"; +import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media"; import { Module } from "modules/Module"; import { type FieldSchema, @@ -13,7 +13,7 @@ import { text, } from "../data/prototype"; import { MediaController } from "./api/MediaController"; -import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; +import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; declare module "core" { @@ -46,6 +46,7 @@ export class AppMedia extends Module { this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); this.setupListeners(); + this.ctx.guard.registerPermissions(MediaPermissions); this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 2c3bc7e1..2b2eaea8 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -54,9 +54,12 @@ export class MediaApi extends ModuleApi { } getUploadHeaders(): Headers { - return new Headers({ - Authorization: `Bearer ${this.options.token}`, - }); + if (this.options.token_transport === "header" && this.options.token) { + return new Headers({ + Authorization: `Bearer ${this.options.token}`, + }); + } + return new Headers(); } protected uploadFile( diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 7ca6db9f..4783be98 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,7 +1,8 @@ import { isDebug, tbValidator as tb } from "core"; import { HttpStatus, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; -import { StorageEvents, getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename, MediaPermissions } from "media"; +import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; @@ -28,18 +29,18 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - const { auth } = this.middlewares; + const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) - hono.get("/files", async (c) => { + hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); }); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", async (c) => { + hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -59,7 +60,7 @@ export class MediaController extends Controller { }); // delete a file by name - hono.delete("/file/:filename", async (c) => { + hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -84,7 +85,7 @@ export class MediaController extends Controller { // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", async (c) => { + hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { const reqname = c.req.param("filename"); const body = await getFileFromContext(c); @@ -114,6 +115,7 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike), }), ), + permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); diff --git a/app/src/media/index.ts b/app/src/media/index.ts index a49a4f9f..d9451ad5 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,6 @@ import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -//export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, @@ -22,6 +21,7 @@ export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; +export * as MediaPermissions from "./media-permissions"; export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts new file mode 100644 index 00000000..714cc2df --- /dev/null +++ b/app/src/media/media-permissions.ts @@ -0,0 +1,6 @@ +import { Permission } from "core"; + +export const readFile = new Permission("media.file.read"); +export const listFiles = new Permission("media.file.list"); +export const uploadFile = new Permission("media.file.upload"); +export const deleteFile = new Permission("media.file.delete"); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9f9d1378..ba1c5662 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -3,6 +3,7 @@ import { type Static, StringEnum } from "core/utils"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; import * as tbbox from "@sinclair/typebox"; +import { AuthException } from "auth/errors"; const { Type } = tbbox; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; @@ -70,12 +71,16 @@ export class AppServer extends Module { this.client.onError((err, c) => { //throw err; - $console.error(err); + $console.error("[AppServer:onError]", err); if (err instanceof Response) { return err; } + if (err instanceof AuthException) { + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); + } + if (err instanceof Exception) { return c.json(err.toJSON(), err.code as any); } diff --git a/app/src/ui/elements/auth/AuthForm.tsx b/app/src/ui/elements/auth/AuthForm.tsx index 53232297..aeae50b0 100644 --- a/app/src/ui/elements/auth/AuthForm.tsx +++ b/app/src/ui/elements/auth/AuthForm.tsx @@ -9,7 +9,6 @@ import { SocialLink } from "./SocialLink"; import type { ValueError } from "@sinclair/typebox/value"; import { type TSchema, Value } from "core/utils"; import type { Validator } from "json-schema-form-react"; -import { useTheme } from "ui/client/use-theme"; import * as tbbox from "@sinclair/typebox"; const { Type } = tbbox; @@ -47,7 +46,6 @@ export function AuthForm({ buttonLabel = action === "login" ? "Sign in" : "Sign up", ...props }: LoginFormProps) { - const { theme } = useTheme(); const basepath = auth?.basepath ?? "/api/auth"; const password = { action: `${basepath}/password/${action}`, diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 5b724c34..73e29a66 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -54,7 +54,6 @@ export function DropzoneContainer({ sort: "-id", }); const entity_name = (media?.entity_name ?? "media") as "media"; - //console.log("dropzone:baseUrl", baseUrl); const selectApi = (api: Api, page: number = 0) => entity diff --git a/bun.lock b/bun.lock index 5da98e03..a35322ec 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.11.0-rc.2", + "version": "0.11.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -43,6 +43,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", @@ -132,6 +133,7 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", + "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -1228,7 +1230,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="], + "@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -1572,7 +1574,7 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], - "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -4064,7 +4066,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="], + "@types/bun/bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="], "@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="], @@ -4660,6 +4662,8 @@ "verdaccio-htpasswd/@verdaccio/file-locking": ["@verdaccio/file-locking@13.0.0-next-8.0", "", { "dependencies": { "lockfile": "1.0.4" } }, "sha512-28XRwpKiE3Z6KsnwE7o8dEM+zGWOT+Vef7RVJyUlG176JVDbGGip3HfCmFioE1a9BklLyGEFTu6D69BzfbRkzA=="], + "verdaccio-htpasswd/bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "verdaccio-htpasswd/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "vite/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],