mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Implement Challenges API
This commit is contained in:
parent
924ff9b2d4
commit
8f9472b221
13
.github/config.workflow.toml
vendored
13
.github/config.workflow.toml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
55
docs/api/challenges.md
Normal 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.
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
1
drizzle/migrations/0027_peaceful_whistler.sql
Normal file
1
drizzle/migrations/0027_peaceful_whistler.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "CaptchaChallenges" RENAME TO "Challenges";
|
||||||
2137
drizzle/migrations/meta/0027_snapshot.json
Normal file
2137
drizzle/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
})
|
||||||
|
.default(
|
||||||
// 5 minutes
|
// 5 minutes
|
||||||
sql`NOW() + INTERVAL '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(),
|
||||||
|
|
|
||||||
38
index.ts
38
index.ts
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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()}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
// Set cookie with JWT
|
|
||||||
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
|
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
|
||||||
60 * 60
|
60 * 60
|
||||||
}`,
|
}`,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 } =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
28
server/api/api/v1/challenges/index.test.ts
Normal file
28
server/api/api/v1/challenges/index.test.ts
Normal 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
server/api/api/v1/challenges/index.ts
Normal file
39
server/api/api/v1/challenges/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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("@")) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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?: {
|
||||||
|
|
|
||||||
148
utils/api.ts
148
utils/api.ts
|
|
@ -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
39
utils/challenges.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue