diff --git a/README.md b/README.md index 35b50df..4cae692 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Backend for movie-web - [ ] provider metrics - [ ] ratelimits (stored in redis) - [X] switch to pnpm - - [ ] catpcha support + - [X] catpcha support - [ ] global namespacing (accounts are stored on a namespace) - [ ] cleanup jobs - [ ] cleanup expired sessions diff --git a/src/config/schema.ts b/src/config/schema.ts index 69a8052..6e32446 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -48,4 +48,13 @@ export const configSchema = z.object({ name: z.string().min(1), description: z.string().min(1).optional(), }), + captcha: z + .object({ + // enabled captchas on register + enabled: z.coerce.boolean().default(false), + + // captcha secret + secret: z.string().min(1).optional(), + }) + .default({}), }); diff --git a/src/routes/auth/manage.ts b/src/routes/auth/manage.ts index 6b7fff5..7032219 100644 --- a/src/routes/auth/manage.ts +++ b/src/routes/auth/manage.ts @@ -1,5 +1,6 @@ import { formatSession } from '@/db/models/Session'; import { User, formatUser } from '@/db/models/User'; +import { assertCaptcha } from '@/services/captcha'; import { handle } from '@/services/handler'; import { makeRouter } from '@/services/router'; import { makeSession, makeSessionToken } from '@/services/session'; @@ -13,6 +14,7 @@ const registerSchema = z.object({ colorB: z.string(), icon: z.string(), }), + captchaToken: z.string().optional(), }); export const manageAuthRouter = makeRouter((app) => { @@ -20,6 +22,8 @@ export const manageAuthRouter = makeRouter((app) => { '/auth/register', { schema: { body: registerSchema } }, handle(async ({ em, body, req }) => { + await assertCaptcha(body.captchaToken); + const user = new User(); user.name = body.name; user.profile = body.profile; diff --git a/src/routes/meta.ts b/src/routes/meta.ts index c55603f..8f89a00 100644 --- a/src/routes/meta.ts +++ b/src/routes/meta.ts @@ -22,6 +22,7 @@ export const metaRouter = makeRouter((app) => { return { name: conf.meta.name, description: conf.meta.description, + hasCaptcha: conf.captcha.enabled, }; }), ); diff --git a/src/services/captcha.ts b/src/services/captcha.ts new file mode 100644 index 0000000..e6cf4ad --- /dev/null +++ b/src/services/captcha.ts @@ -0,0 +1,28 @@ +import { conf } from '@/config'; +import { StatusError } from '@/services/error'; + +export async function isValidCaptcha(token: string): Promise { + if (!conf.captcha.secret) + throw new Error('isValidCaptcha() is called but no secret set'); + const res = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + body: JSON.stringify({ + secret: conf.captcha.secret, + response: token, + }), + headers: { + 'content-type': 'application/json', + }, + }); + const json = await res.json(); + return !!json.success; +} + +export async function assertCaptcha(token?: string) { + // early return if captchas arent enabled + if (!conf.captcha.enabled) return; + if (!token) throw new StatusError('captcha token is required', 400); + + const isValid = await isValidCaptcha(token); + if (!isValid) throw new StatusError('captcha token is invalid', 400); +}