aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/server/api/client.ts29
-rw-r--r--apps/web/server/auth.ts11
-rw-r--r--packages/shared/logger.ts19
-rw-r--r--packages/trpc/auth.ts11
-rw-r--r--packages/trpc/index.ts6
-rw-r--r--packages/trpc/routers/apiKeys.ts27
-rw-r--r--packages/trpc/testUtils.ts3
-rw-r--r--pnpm-lock.yaml20
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<Context> => {
+export const createContext = async (
+ database?: typeof db,
+ ip?: string | null,
+): Promise<Context> => {
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("<unknown>", 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