From 0debc6b415baa466245901fb52c009d09ef3ba15 Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sat, 19 Oct 2024 22:24:26 +0200 Subject: [PATCH] feature: Log authentication failures to support fail2ban. Fixes #477 (#569) * How do I set the variable "user" or "system" for AI inference #262 changed from system to user * [Feature Request] Log failed login attempts for fail2ban implementation #477 added logging of failed logins * [Feature Request] Log failed login attempts for fail2ban implementation #477 added more logging for extension related logins * Propagte IP to trpc --------- Co-authored-by: Your Name --- apps/web/package.json | 2 ++ apps/web/server/api/client.ts | 29 ++++++++++++++++++++++++++--- apps/web/server/auth.ts | 11 +++++++++-- packages/shared/logger.ts | 19 +++++++++++++++++++ packages/trpc/auth.ts | 11 +++++++++++ packages/trpc/index.ts | 6 ++++++ packages/trpc/routers/apiKeys.ts | 27 ++++++++++++++++++++------- packages/trpc/testUtils.ts | 3 +++ pnpm-lock.yaml | 20 ++++++++++++++++++++ 9 files changed, 116 insertions(+), 12 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index e6691563..cbc01a50 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -68,6 +68,7 @@ "react-syntax-highlighter": "^15.5.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", + "request-ip": "^3.3.0", "sharp": "^0.33.3", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", @@ -83,6 +84,7 @@ "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-syntax-highlighter": "^15.5.13", + "@types/request-ip": "^0.0.41", "autoprefixer": "^10.4.17", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts index 6a0a8909..fb2d84bc 100644 --- a/apps/web/server/api/client.ts +++ b/apps/web/server/api/client.ts @@ -1,4 +1,6 @@ +import { headers } from "next/headers"; import { getServerAuthSession } from "@/server/auth"; +import requestIp from "request-ip"; import { db } from "@hoarder/db"; import { Context, createCallerFactory } from "@hoarder/trpc"; @@ -8,25 +10,46 @@ import { appRouter } from "@hoarder/trpc/routers/_app"; export async function createContextFromRequest(req: Request) { // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. + const ip = requestIp.getClientIp({ + headers: Object.fromEntries(req.headers.entries()), + }); const authorizationHeader = req.headers.get("Authorization"); if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { const token = authorizationHeader.split(" ")[1]; try { const user = await authenticateApiKey(token); - return { user, db }; + return { + user, + db, + req: { + ip, + }, + }; } catch (e) { // Fallthrough to cookie-based auth } } - return createContext(); + return createContext(db, ip); } -export const createContext = async (database?: typeof db): Promise => { +export const createContext = async ( + database?: typeof db, + ip?: string | null, +): Promise => { const session = await getServerAuthSession(); + if (ip === undefined) { + const hdrs = headers(); + ip = requestIp.getClientIp({ + headers: Object.fromEntries(hdrs.entries()), + }); + } return { user: session?.user ?? null, db: database ?? db, + req: { + ip, + }, }; }; diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 042be1ae..ee226743 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -8,6 +8,7 @@ import NextAuth, { } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { Provider } from "next-auth/providers/index"; +import requestIp from "request-ip"; import { db } from "@hoarder/db"; import { @@ -17,7 +18,7 @@ import { verificationTokens, } from "@hoarder/db/schema"; import serverConfig from "@hoarder/shared/config"; -import { validatePassword } from "@hoarder/trpc/auth"; +import { logAuthenticationError, validatePassword } from "@hoarder/trpc/auth"; type UserRole = "admin" | "user"; @@ -77,7 +78,7 @@ const providers: Provider[] = [ email: { label: "Email", type: "email", placeholder: "Email" }, password: { label: "Password", type: "password" }, }, - async authorize(credentials) { + async authorize(credentials, req) { if (!credentials) { return null; } @@ -88,6 +89,12 @@ const providers: Provider[] = [ credentials?.password, ); } catch (e) { + const error = e as Error; + logAuthenticationError( + credentials?.email, + error.message, + requestIp.getClientIp({ headers: req.headers }), + ); return null; } }, diff --git a/packages/shared/logger.ts b/packages/shared/logger.ts index f406b447..f3aa3cb9 100644 --- a/packages/shared/logger.ts +++ b/packages/shared/logger.ts @@ -15,3 +15,22 @@ const logger = winston.createLogger({ }); export default logger; + +export const authFailureLogger = winston.createLogger({ + level: "debug", + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf( + (info) => `${info.timestamp} ${info.level}: ${info.message}`, + ), + ), + transports: [ + new winston.transports.Console(), + new winston.transports.File({ + filename: "auth_failures.log", + dirname: serverConfig.dataDir, + maxFiles: 2, + maxsize: 1024 * 1024, + }), + ], +}); diff --git a/packages/trpc/auth.ts b/packages/trpc/auth.ts index 39aebd3b..1efbdde6 100644 --- a/packages/trpc/auth.ts +++ b/packages/trpc/auth.ts @@ -4,6 +4,7 @@ import * as bcrypt from "bcryptjs"; import { db } from "@hoarder/db"; import { apiKeys } from "@hoarder/db/schema"; import serverConfig from "@hoarder/shared/config"; +import { authFailureLogger } from "@hoarder/shared/logger"; // API Keys @@ -102,3 +103,13 @@ export async function validatePassword(email: string, password: string) { return user; } + +export function logAuthenticationError( + user: string, + message: string, + ip: string | null, +): void { + authFailureLogger.error( + `Authentication error. User: "${user}", Message: "${message}", IP-Address: "${ip}"`, + ); +} diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts index 5f351a8e..26d8ea96 100644 --- a/packages/trpc/index.ts +++ b/packages/trpc/index.ts @@ -15,11 +15,17 @@ interface User { export interface Context { user: User | null; db: typeof db; + req: { + ip: string | null; + }; } export interface AuthedContext { user: User; db: typeof db; + req: { + ip: string | null; + }; } // Avoid exporting the entire t-object diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts index b7468dd2..c55dc095 100644 --- a/packages/trpc/routers/apiKeys.ts +++ b/packages/trpc/routers/apiKeys.ts @@ -5,7 +5,12 @@ import { z } from "zod"; import { apiKeys } from "@hoarder/db/schema"; import serverConfig from "@hoarder/shared/config"; -import { authenticateApiKey, generateApiKey, validatePassword } from "../auth"; +import { + authenticateApiKey, + generateApiKey, + logAuthenticationError, + validatePassword, +} from "../auth"; import { authedProcedure, publicProcedure, router } from "../index"; const zApiKeySchema = z.object({ @@ -73,7 +78,7 @@ export const apiKeysAppRouter = router({ }), ) .output(zApiKeySchema) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { let user; // Special handling as otherwise the extension would show "username or password is wrong" if (serverConfig.auth.disablePasswordAuth) { @@ -85,6 +90,8 @@ export const apiKeysAppRouter = router({ try { user = await validatePassword(input.email, input.password); } catch (e) { + const error = e as Error; + logAuthenticationError(input.email, error.message, ctx.req.ip); throw new TRPCError({ code: "UNAUTHORIZED" }); } return await generateApiKey(input.keyName, user.id); @@ -92,10 +99,16 @@ export const apiKeysAppRouter = router({ validate: publicProcedure .input(z.object({ apiKey: z.string() })) .output(z.object({ success: z.boolean() })) - .mutation(async ({ input }) => { - await authenticateApiKey(input.apiKey); // Throws if the key is invalid - return { - success: true, - }; + .mutation(async ({ input, ctx }) => { + try { + await authenticateApiKey(input.apiKey); // Throws if the key is invalid + return { + success: true, + }; + } catch (e) { + const error = e as Error; + logAuthenticationError("", error.message, ctx.req.ip); + throw e; + } }), }); diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts index 04e6b0a3..23dcdb33 100644 --- a/packages/trpc/testUtils.ts +++ b/packages/trpc/testUtils.ts @@ -37,6 +37,9 @@ export function getApiCaller(db: TestDB, userId?: string, email?: string) { } : null, db, + req: { + ip: null, + }, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d26e527..046261ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,9 @@ importers: remark-gfm: specifier: ^4.0.0 version: 4.0.0 + request-ip: + specifier: ^3.3.0 + version: 3.3.0 sharp: specifier: ^0.33.3 version: 0.33.3 @@ -656,6 +659,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@types/request-ip': + specifier: ^0.0.41 + version: 0.0.41 autoprefixer: specifier: ^10.4.17 version: 10.4.17(postcss@8.4.35) @@ -4414,6 +4420,9 @@ packages: '@types/react@18.2.58': resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==} + '@types/request-ip@0.0.41': + resolution: {integrity: sha512-Qzz0PM2nSZej4lsLzzNfADIORZhhxO7PED0fXpg4FjXiHuJ/lMyUg+YFF5q8x9HPZH3Gl6N+NOM8QZjItNgGKg==} + '@types/resolve@1.17.1': resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} @@ -10836,6 +10845,9 @@ packages: renderkid@3.0.0: resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + request-ip@3.3.0: + resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -18577,6 +18589,11 @@ snapshots: '@types/scheduler': 0.16.8 csstype: 3.1.3 + '@types/request-ip@0.0.41': + dependencies: + '@types/node': 20.11.20 + dev: true + '@types/resolve@1.17.1': dependencies: '@types/node': 20.11.20 @@ -27575,6 +27592,9 @@ snapshots: strip-ansi: 6.0.1 dev: false + request-ip@3.3.0: + dev: false + require-directory@2.1.1: dev: false