feat(api): Implement Challenges API

This commit is contained in:
Jesse Wierzbinski 2024-06-13 22:03:51 -10:00
parent 924ff9b2d4
commit 8f9472b221
No known key found for this signature in database
26 changed files with 2656 additions and 104 deletions

View file

@ -183,6 +183,19 @@ allowed_mime_types = [
"video/x-ms-asf", "video/x-ms-asf",
] ]
[validation.challenges]
# "Challenges" (aka captchas) are a way to verify that a user is human
# Lysand's challenges use no external services, and are Proof of Work based
# This means that they do not require any user interaction, instead
# they require the user's computer to do a small amount of work
enabled = true
# The difficulty of the challenge, higher is harder
difficulty = 50000
# Challenge expiration time in seconds
expiration = 300 # 5 minutes
# Leave this empty to generate a new key
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes
visibility = "public" visibility = "public"

View file

@ -47,6 +47,7 @@
- [x] Fully written in TypeScript and thoroughly unit tested - [x] Fully written in TypeScript and thoroughly unit tested
- [x] Automatic signed container builds for easy deployment - [x] Automatic signed container builds for easy deployment
- [x] Docker and Podman supported - [x] Docker and Podman supported
- [x] Invisible, Proof-of-Work local CAPTCHA for API requests
## Screenshots ## Screenshots

View file

@ -252,6 +252,19 @@ enforce_mime_types = false
# Defaults to all valid MIME types # Defaults to all valid MIME types
# allowed_mime_types = [] # allowed_mime_types = []
[validation.challenges]
# "Challenges" (aka captchas) are a way to verify that a user is human
# Lysand's challenges use no external services, and are Proof of Work based
# This means that they do not require any user interaction, instead
# they require the user's computer to do a small amount of work
enabled = false
# The difficulty of the challenge, higher is will take more time to solve
difficulty = 50000
# Challenge expiration time in seconds
expiration = 300 # 5 minutes
# Leave this empty to generate a new key
key = ""
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes
# Can be public, unlisted, private or direct # Can be public, unlisted, private or direct

55
docs/api/challenges.md Normal file
View file

@ -0,0 +1,55 @@
# Challenges API
Some API routes may require a cryptographic challenge to be solved before the request can be made. This is to prevent abuse of the API by bots and other malicious actors. The challenge is a simple mathematical problem that can be solved by any client.
This is a form of proof of work CAPTCHA, and should be mostly invisible to users. The challenge is generated by the server and sent to the client, which must solve it and send the solution back to the server.
## Solving a Challenge
Challenges are powered by the [Altcha](https://altcha.org/) library. You may either reimplement their solution code (which is very simple), or use [`altcha-lib`](https://github.com/altcha-org/altcha-lib) to solve the challenges.
## Request Challenge
```http
POST /api/v1/challenges
```
Generates a new challenge for the client to solve.
### Response
```ts
// 200 OK
{
id: string,
algorithm: "SHA-256" | "SHA-384" | "SHA-512",
challenge: string;
maxnumber?: number;
salt: string;
signature: string;
}
```
## Sending a Solution
To send a solution with any request, add the following headers:
- `X-Challenge-Solution`: A base64 encoded string of the following JSON object:
```ts
{
number: number; // Solution to the challenge
algorithm: "SHA-256" | "SHA-384" | "SHA-512";
challenge: string;
salt: string,
signature: string,
}
```
Example: `{"number": 42, "algorithm": "SHA-256", "challenge": "xxxx", "salt": "abc", "signature": "def"}` -> `eyJudW1iZXIiOjQyLCJhbGdvcml0aG0iOiJTSEEtMjU2IiwiY2hhbGxlbmdlIjoieHh4eCIsInNhbHQiOiJhYmMiLCJzaWduYXR1cmUiOiJkZWYifQ==`
A challenge solution is valid for 5 minutes (configurable) after the challenge is generated. No solved challenge may be used more than once.
## Routes Requiring Challenges
If challenges are enabled, the following routes will require a challenge to be solved before the request can be made:
- `POST /api/v1/accounts`
Which routes require challenges may eventually be expanded or made configurable.

View file

@ -12,6 +12,10 @@ For client developers. Please read [the documentation](./emojis.md).
For client developers. Please read [the documentation](./roles.md). For client developers. Please read [the documentation](./roles.md).
## Challenges API
For client developers. Please read [the documentation](./challenges.md).
## Moderation API ## Moderation API
> [!WARNING] > [!WARNING]

View file

@ -0,0 +1 @@
ALTER TABLE "CaptchaChallenges" RENAME TO "Challenges";

File diff suppressed because it is too large Load diff

View file

@ -190,6 +190,13 @@
"when": 1718234302625, "when": 1718234302625,
"tag": "0026_neat_stranger", "tag": "0026_neat_stranger",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1718327596823,
"tag": "0027_peaceful_whistler",
"breakpoints": true
} }
] ]
} }

View file

@ -16,16 +16,18 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { Source as apiSource } from "~/types/mastodon/source"; import type { Source as apiSource } from "~/types/mastodon/source";
export const CaptchaChallenges = pgTable("CaptchaChallenges", { export const Challenges = pgTable("Challenges", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
challenge: jsonb("challenge").notNull().$type<Challenge>(), challenge: jsonb("challenge").notNull().$type<Challenge>(),
expiresAt: timestamp("expires_at", { expiresAt: timestamp("expires_at", {
precision: 3, precision: 3,
mode: "string", mode: "string",
}).default( })
// 5 minutes .default(
sql`NOW() + INTERVAL '5 minutes'`, // 5 minutes
), sql`NOW() + INTERVAL '5 minutes'`,
)
.notNull(),
createdAt: timestamp("created_at", { precision: 3, mode: "string" }) createdAt: timestamp("created_at", { precision: 3, mode: "string" })
.defaultNow() .defaultNow()
.notNull(), .notNull(),

View file

@ -106,6 +106,44 @@ if (isEntry) {
); );
process.exit(1); process.exit(1);
} }
if (
config.validation.challenges.enabled &&
!config.validation.challenges.key
) {
await dualServerLogger.log(
LogLevel.Critical,
"Server",
"Challenges are enabled, but the challenge key is not set in the config",
);
await dualServerLogger.log(
LogLevel.Critical,
"Server",
"Below is a generated key for you to copy in the config at validation.challenges.key",
);
const key = await crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
},
true,
["sign"],
);
const exported = await crypto.subtle.exportKey("raw", key);
const base64 = Buffer.from(exported).toString("base64");
await dualServerLogger.log(
LogLevel.Critical,
"Server",
`Generated key: ${chalk.gray(base64)}`,
);
process.exit(1);
}
} }
const app = new Hono({ const app = new Hono({

View file

@ -330,6 +330,19 @@ export const configValidator = z.object({
allowed_mime_types: z allowed_mime_types: z
.array(z.string()) .array(z.string())
.default(Object.values(mimeTypes)), .default(Object.values(mimeTypes)),
challenges: z
.object({
enabled: z.boolean().default(true),
difficulty: z.number().int().positive().default(50000),
expiration: z.number().int().positive().default(300),
key: z.string().default(""),
})
.default({
enabled: true,
difficulty: 50000,
expiration: 300,
key: "",
}),
}) })
.default({ .default({
max_displayname_size: 50, max_displayname_size: 50,
@ -399,6 +412,12 @@ export const configValidator = z.object({
], ],
enforce_mime_types: false, enforce_mime_types: false,
allowed_mime_types: Object.values(mimeTypes), allowed_mime_types: Object.values(mimeTypes),
challenges: {
enabled: true,
difficulty: 50000,
expiration: 300,
key: "",
},
}), }),
defaults: z defaults: z
.object({ .object({

View file

@ -53,7 +53,7 @@ describe(meta.route, () => {
expect(response.headers.get("location")).toBeDefined(); expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL( const locationHeader = new URL(
response.headers.get("Location") ?? "", response.headers.get("Location") ?? "",
"", config.http.base_url,
); );
expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.pathname).toBe("/oauth/consent");
@ -92,7 +92,7 @@ describe(meta.route, () => {
expect(response.headers.get("location")).toBeDefined(); expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL( const locationHeader = new URL(
response.headers.get("Location") ?? "", response.headers.get("Location") ?? "",
"", config.http.base_url,
); );
expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.pathname).toBe("/oauth/consent");

View file

@ -1,5 +1,5 @@
import { applyConfig, handleZodError } from "@/api"; import { applyConfig, handleZodError } from "@/api";
import { errorResponse, response } from "@/response"; import { errorResponse, redirect } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { eq, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
@ -73,12 +73,12 @@ const returnError = (query: object, error: string, description: string) => {
searchParams.append("error", error); searchParams.append("error", error);
searchParams.append("error_description", description); searchParams.append("error_description", description);
return response(null, 302, { return redirect(
Location: new URL( new URL(
`${config.frontend.routes.login}?${searchParams.toString()}`, `${config.frontend.routes.login}?${searchParams.toString()}`,
config.http.base_url, config.http.base_url,
).toString(), ),
}); );
}; };
export default (app: Hono) => export default (app: Hono) =>
@ -124,17 +124,14 @@ export default (app: Hono) =>
} }
if (user.data.passwordResetToken) { if (user.data.passwordResetToken) {
return response(null, 302, { return redirect(
Location: new URL( `${
`${ config.frontend.routes.password_reset
config.frontend.routes.password_reset }?${new URLSearchParams({
}?${new URLSearchParams({ token: user.data.passwordResetToken ?? "",
token: user.data.passwordResetToken ?? "", login_reset: "true",
login_reset: "true", }).toString()}`,
}).toString()}`, );
config.http.base_url,
).toString(),
});
} }
// Try and import the key // Try and import the key
@ -186,17 +183,14 @@ export default (app: Hono) =>
} }
// Redirect to OAuth authorize with JWT // Redirect to OAuth authorize with JWT
return response(null, 302, { return redirect(
Location: new URL( `${config.frontend.routes.consent}?${searchParams.toString()}`,
`${ 302,
config.frontend.routes.consent {
}?${searchParams.toString()}`, "Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
config.http.base_url, 60 * 60
).toString(), }`,
// Set cookie with JWT },
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ );
60 * 60
}`,
});
}, },
); );

View file

@ -82,7 +82,7 @@ describe(meta.route, () => {
expect(response.headers.get("location")).toBeDefined(); expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL( const locationHeader = new URL(
response.headers.get("Location") ?? "", response.headers.get("Location") ?? "",
"", config.http.base_url,
); );
expect(locationHeader.pathname).toBe("/oauth/reset"); expect(locationHeader.pathname).toBe("/oauth/reset");
@ -128,7 +128,7 @@ describe(meta.route, () => {
expect(loginResponse.headers.get("location")).toBeDefined(); expect(loginResponse.headers.get("location")).toBeDefined();
const locationHeader = new URL( const locationHeader = new URL(
loginResponse.headers.get("Location") ?? "", loginResponse.headers.get("Location") ?? "",
"", config.http.base_url,
); );
expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.pathname).toBe("/oauth/consent");

View file

@ -108,7 +108,7 @@ export default (app: Hono) =>
await Promise.all(objects.map((note) => note.toApi(otherUser))), await Promise.all(objects.map((note) => note.toApi(otherUser))),
200, 200,
{ {
Link: link, link,
}, },
); );
}, },

View file

@ -4,7 +4,7 @@ import { config } from "config-manager";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Users } from "~/drizzle/schema"; import { Users } from "~/drizzle/schema";
import { sendTestRequest } from "~/tests/utils"; import { getSolvedChallenge, sendTestRequest } from "~/tests/utils";
import { meta } from "./index"; import { meta } from "./index";
const username = randomString(10, "hex"); const username = randomString(10, "hex");
@ -23,6 +23,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@ -44,6 +45,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@ -65,6 +67,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@ -85,6 +88,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@ -102,6 +106,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username2, username: username2,
@ -123,6 +128,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username, username: username,
@ -140,6 +146,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: username2, username: username2,
@ -161,6 +168,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: "bob$", username: "bob$",
@ -180,6 +188,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: "bob-markey", username: "bob-markey",
@ -199,6 +208,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: "bob markey", username: "bob markey",
@ -218,6 +228,7 @@ describe(meta.route, () => {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
}, },
body: JSON.stringify({ body: JSON.stringify({
username: "BOB", username: "BOB",

View file

@ -21,6 +21,9 @@ export const meta = applyConfig({
required: false, required: false,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
challenge: {
required: true,
},
}); });
export const schemas = { export const schemas = {
@ -41,9 +44,9 @@ export default (app: Hono) =>
app.on( app.on(
meta.allowedMethods, meta.allowedMethods,
meta.route, meta.route,
auth(meta.auth, meta.permissions, meta.challenge),
jsonOrForm(), jsonOrForm(),
zValidator("form", schemas.form, handleZodError), zValidator("form", schemas.form, handleZodError),
auth(meta.auth, meta.permissions),
async (context) => { async (context) => {
const form = context.req.valid("form"); const form = context.req.valid("form");
const { username, email, password, agreement, locale } = const { username, email, password, agreement, locale } =

View file

@ -39,15 +39,49 @@ export const schemas = {
.min(3) .min(3)
.trim() .trim()
.max(config.validation.max_displayname_size) .max(config.validation.max_displayname_size)
.refine(
(s) =>
!config.filters.displayname.some((filter) =>
s.match(filter),
),
"Display name contains blocked words",
)
.optional(),
username: z
.string()
.min(3)
.trim()
.max(config.validation.max_username_size)
.refine(
(s) =>
!config.filters.username.some((filter) => s.match(filter)),
"Username contains blocked words",
)
.optional(), .optional(),
note: z note: z
.string() .string()
.min(0) .min(0)
.max(config.validation.max_bio_size) .max(config.validation.max_bio_size)
.trim() .trim()
.refine(
(s) => !config.filters.bio.some((filter) => s.match(filter)),
"Bio contains blocked words",
)
.optional(),
avatar: z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_avatar_size,
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
)
.optional(),
header: z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_header_size,
`Header must be less than ${config.validation.max_header_size} bytes`,
)
.optional(), .optional(),
avatar: z.instanceof(File).optional(),
header: z.instanceof(File).optional(),
locked: z locked: z
.string() .string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) .transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
@ -105,6 +139,7 @@ export default (app: Hono) =>
const { user } = context.req.valid("header"); const { user } = context.req.valid("header");
const { const {
display_name, display_name,
username,
note, note,
avatar, avatar,
header, header,
@ -131,27 +166,10 @@ export default (app: Hono) =>
); );
if (display_name) { if (display_name) {
// Check if display name doesnt match filters
if (
config.filters.displayname.some((filter) =>
sanitizedDisplayName.match(filter),
)
) {
return errorResponse(
"Display name contains blocked words",
422,
);
}
self.displayName = sanitizedDisplayName; self.displayName = sanitizedDisplayName;
} }
if (note && self.source) { if (note && self.source) {
// Check if bio doesnt match filters
if (config.filters.bio.some((filter) => note.match(filter))) {
return errorResponse("Bio contains blocked words", 422);
}
self.source.note = note; self.source.note = note;
self.note = await contentToHtml({ self.note = await contentToHtml({
"text/markdown": { "text/markdown": {
@ -172,29 +190,17 @@ export default (app: Hono) =>
self.source.language = source.language; self.source.language = source.language;
} }
if (avatar) { if (username) {
// Check if within allowed avatar length (avatar is an image) self.username = username;
if (avatar.size > config.validation.max_avatar_size) { }
return errorResponse(
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
422,
);
}
if (avatar) {
const { path } = await mediaManager.addFile(avatar); const { path } = await mediaManager.addFile(avatar);
self.avatar = getUrl(path, config); self.avatar = getUrl(path, config);
} }
if (header) { if (header) {
// Check if within allowed header length (header is an image)
if (header.size > config.validation.max_header_size) {
return errorResponse(
`Header must be less than ${config.validation.max_avatar_size} bytes`,
422,
);
}
const { path } = await mediaManager.addFile(header); const { path } = await mediaManager.addFile(header);
self.header = getUrl(path, config); self.header = getUrl(path, config);

View file

@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { sendTestRequest } from "~/tests/utils";
import { meta } from "./index";
// /api/v1/challenges
describe(meta.route, () => {
test("should get a challenge", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
method: "POST",
}),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toMatchObject({
id: expect.any(String),
algorithm: expect.any(String),
challenge: expect.any(String),
maxnumber: expect.any(Number),
salt: expect.any(String),
signature: expect.any(String),
});
});
});

View file

@ -0,0 +1,39 @@
import { applyConfig, auth } from "@/api";
import { generateChallenge } from "@/challenges";
import { errorResponse, jsonResponse } from "@/response";
import type { Hono } from "hono";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
route: "/api/v1/challenges",
ratelimits: {
max: 10,
duration: 60,
},
auth: {
required: false,
},
permissions: {
required: [],
},
});
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
auth(meta.auth, meta.permissions),
async (_context) => {
if (!config.validation.challenges.enabled) {
return errorResponse("Challenges are disabled in config", 400);
}
const result = await generateChallenge();
return jsonResponse({
id: result.id,
...result.challenge,
});
},
);

View file

@ -1,4 +1,4 @@
import { applyConfig, auth, handleZodError } from "@/api"; import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api";
import { dualLogger } from "@/loggers"; import { dualLogger } from "@/loggers";
import { MeiliIndexType, meilisearch } from "@/meilisearch"; import { MeiliIndexType, meilisearch } from "@/meilisearch";
import { errorResponse, jsonResponse } from "@/response"; import { errorResponse, jsonResponse } from "@/response";
@ -75,9 +75,7 @@ export default (app: Hono) =>
if (!type || type === "accounts") { if (!type || type === "accounts") {
// Check if q is matching format username@domain.com or @username@domain.com // Check if q is matching format username@domain.com or @username@domain.com
const accountMatches = q const accountMatches = q?.trim().match(userAddressValidator);
?.trim()
.match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g);
if (accountMatches) { if (accountMatches) {
// Remove leading @ if it exists // Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) { if (accountMatches[0].startsWith("@")) {

View file

@ -1,5 +1,7 @@
import { generateChallenge } from "@/challenges";
import { consoleLogger } from "@/loggers"; import { consoleLogger } from "@/loggers";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { solveChallenge } from "altcha-lib";
import { asc, inArray, like } from "drizzle-orm"; import { asc, inArray, like } from "drizzle-orm";
import type { Status } from "~/database/entities/status"; import type { Status } from "~/database/entities/status";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
@ -118,3 +120,34 @@ export const getTestStatuses = async (
) )
).map((n) => n.data); ).map((n) => n.data);
}; };
/**
* Generates a solved challenge (with tiny difficulty)
*
* Only to be used in tests
* @returns Base64 encoded payload
*/
export const getSolvedChallenge = async () => {
const { challenge } = await generateChallenge(100);
const solution = await solveChallenge(
challenge.challenge,
challenge.salt,
challenge.algorithm,
challenge.maxnumber,
).promise;
if (!solution) {
throw new Error("Failed to solve challenge");
}
return Buffer.from(
JSON.stringify({
number: solution.number,
algorithm: challenge.algorithm,
challenge: challenge.challenge,
salt: challenge.salt,
signature: challenge.signature,
}),
).toString("base64");
};

View file

@ -18,6 +18,12 @@ export interface ApiRouteMetadata {
}; };
oauthPermissions?: string[]; oauthPermissions?: string[];
}; };
challenge?: {
required: boolean;
methodOverrides?: {
[Key in HttpVerb]?: boolean;
};
};
permissions?: { permissions?: {
required: RolePermissions[]; required: RolePermissions[];
methodOverrides?: { methodOverrides?: {

View file

@ -1,8 +1,11 @@
import { errorResponse } from "@/response"; import { errorResponse } from "@/response";
import { extractParams, verifySolution } from "altcha-lib";
import chalk from "chalk"; import chalk from "chalk";
import { config } from "config-manager"; import { config } from "config-manager";
import { eq } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import type { StatusCode } from "hono/utils/http-status";
import { validator } from "hono/validator"; import { validator } from "hono/validator";
import { import {
anyOf, anyOf,
@ -21,6 +24,8 @@ import type { z } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import type { Application } from "~/database/entities/application"; import type { Application } from "~/database/entities/application";
import { type AuthData, getFromHeader } from "~/database/entities/user"; import { type AuthData, getFromHeader } from "~/database/entities/user";
import { db } from "~/drizzle/db";
import { Challenges } from "~/drizzle/schema";
import type { User } from "~/packages/database-interface/user"; import type { User } from "~/packages/database-interface/user";
import { LogLevel, LogManager } from "~/packages/log-manager"; import { LogLevel, LogManager } from "~/packages/log-manager";
import type { ApiRouteMetadata, HttpVerb } from "~/types/api"; import type { ApiRouteMetadata, HttpVerb } from "~/types/api";
@ -79,6 +84,18 @@ export const mentionValidator = createRegExp(
[global], [global],
); );
export const userAddressValidator = createRegExp(
maybe("@"),
oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs(
"username",
),
maybe(
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"),
),
[global],
);
export const webfingerMention = createRegExp( export const webfingerMention = createRegExp(
exactly("acct:"), exactly("acct:"),
oneOrMore(anyOf(letter, digit, charIn("-"))).groupedAs("username"), oneOrMore(anyOf(letter, digit, charIn("-"))).groupedAs("username"),
@ -106,6 +123,22 @@ const getAuth = async (value: Record<string, string>) => {
: null; : null;
}; };
const returnContextError = (
context: Context,
error: string,
code?: StatusCode,
) => {
const templateError = errorResponse(error, code);
return context.json(
{
error,
},
code,
templateError.headers.toJSON(),
);
};
const checkPermissions = ( const checkPermissions = (
auth: AuthData | null, auth: AuthData | null,
permissionData: ApiRouteMetadata["permissions"], permissionData: ApiRouteMetadata["permissions"],
@ -118,18 +151,15 @@ const checkPermissions = (
permissionData?.methodOverrides?.[context.req.method as HttpVerb] ?? permissionData?.methodOverrides?.[context.req.method as HttpVerb] ??
permissionData?.required ?? permissionData?.required ??
[]; [];
const error = errorResponse("Unauthorized", 401);
if (!requiredPerms.every((perm) => userPerms.includes(perm))) { if (!requiredPerms.every((perm) => userPerms.includes(perm))) {
const missingPerms = requiredPerms.filter( const missingPerms = requiredPerms.filter(
(perm) => !userPerms.includes(perm), (perm) => !userPerms.includes(perm),
); );
return context.json( return returnContextError(
{ context,
error: `You do not have the required permissions to access this route. Missing: ${missingPerms.join(", ")}`, `You do not have the required permissions to access this route. Missing: ${missingPerms.join(", ")}`,
},
403, 403,
error.headers.toJSON(),
); );
} }
}; };
@ -139,8 +169,6 @@ const checkRouteNeedsAuth = (
authData: ApiRouteMetadata["auth"], authData: ApiRouteMetadata["auth"],
context: Context, context: Context,
) => { ) => {
const error = errorResponse("Unauthorized", 401);
if (auth?.user) { if (auth?.user) {
return { return {
user: auth.user as User, user: auth.user as User,
@ -148,23 +176,14 @@ const checkRouteNeedsAuth = (
application: auth.application as Application | null, application: auth.application as Application | null,
}; };
} }
if (authData.required) { if (
return context.json( authData.required ||
{ authData.methodOverrides?.[context.req.method as HttpVerb]
error: "Unauthorized", ) {
}, return returnContextError(
context,
"This route requires authentication.",
401, 401,
error.headers.toJSON(),
);
}
if (authData.methodOverrides?.[context.req.method as HttpVerb]) {
return context.json(
{
error: "Unauthorized",
},
401,
error.headers.toJSON(),
); );
} }
@ -175,9 +194,80 @@ const checkRouteNeedsAuth = (
}; };
}; };
export const checkRouteNeedsChallenge = async (
challengeData: ApiRouteMetadata["challenge"],
context: Context,
) => {
if (!challengeData) {
return true;
}
const challengeSolution = context.req.header("X-Challenge-Solution");
if (!challengeSolution) {
return returnContextError(
context,
"This route requires a challenge solution to be sent to it via the X-Challenge-Solution header. Please check the documentation for more information.",
401,
);
}
const { challenge_id } = extractParams(challengeSolution);
if (!challenge_id) {
return returnContextError(
context,
"The challenge solution provided is invalid.",
401,
);
}
const challenge = await db.query.Challenges.findFirst({
where: (c, { eq }) => eq(c.id, challenge_id),
});
if (!challenge) {
return returnContextError(
context,
"The challenge solution provided is invalid.",
401,
);
}
if (new Date(challenge.expiresAt) < new Date()) {
return returnContextError(
context,
"The challenge provided has expired.",
401,
);
}
const isValid = await verifySolution(
challengeSolution,
config.validation.challenges.key,
);
if (!isValid) {
return returnContextError(
context,
"The challenge solution provided is incorrect.",
401,
);
}
// Expire the challenge
await db
.update(Challenges)
.set({ expiresAt: new Date().toISOString() })
.where(eq(Challenges.id, challenge_id));
return true;
};
export const auth = ( export const auth = (
authData: ApiRouteMetadata["auth"], authData: ApiRouteMetadata["auth"],
permissionData?: ApiRouteMetadata["permissions"], permissionData?: ApiRouteMetadata["permissions"],
challengeData?: ApiRouteMetadata["challenge"],
) => ) =>
validator("header", async (value, context) => { validator("header", async (value, context) => {
const auth = await getAuth(value); const auth = await getAuth(value);
@ -194,6 +284,16 @@ export const auth = (
} }
} }
if (challengeData) {
const challengeCheck = await checkRouteNeedsChallenge(
challengeData,
context,
);
if (challengeCheck !== true) {
return challengeCheck;
}
}
return checkRouteNeedsAuth(auth, authData, context); return checkRouteNeedsAuth(auth, authData, context);
}); });

39
utils/challenges.ts Normal file
View file

@ -0,0 +1,39 @@
import { createChallenge } from "altcha-lib";
import { sql } from "drizzle-orm";
import { db } from "~/drizzle/db";
import { Challenges } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
export const generateChallenge = async (
maxNumber = config.validation.challenges.difficulty,
) => {
const expirationDate = new Date(
Date.now() + config.validation.challenges.expiration * 1000,
);
const uuid = (await db.execute(sql<string>`SELECT uuid_generate_v7()`))
.rows[0].uuid_generate_v7 as string;
const challenge = await createChallenge({
hmacKey: config.validation.challenges.key,
expires: expirationDate,
maxNumber,
algorithm: "SHA-256",
params: {
challenge_id: uuid,
},
});
const result = (
await db
.insert(Challenges)
.values({
id: uuid,
challenge,
expiresAt: expirationDate.toISOString(),
})
.returning()
)[0];
return result;
};

View file

@ -53,9 +53,14 @@ export const errorResponse = (error: string, status = 500) => {
); );
}; };
export const redirect = (url: string | URL, status = 302) => { export const redirect = (
url: string | URL,
status = 302,
extraHeaders: Record<string, string> = {},
) => {
return response(null, status, { return response(null, status, {
Location: url.toString(), Location: url.toString(),
...extraHeaders,
}); });
}; };