-
Notifications
You must be signed in to change notification settings - Fork 0
feat: secure admin authentication with obfuscated paths #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b28dd25
f73ca46
b7781e6
5153cd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -43,10 +43,14 @@ The application uses Prisma ORM with PostgreSQL. The schema defines two main mod | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Date picker for filtering books by completion date | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Default date range is current year (Jan 1 - Dec 31) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - **pages/admin.tsx**: Admin page for adding books | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Contains `upsertBooksAndAuthors` function for inserting/updating books | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Handles many-to-many author relationships | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - **Note**: This page runs on client-side and directly calls Prisma, which will fail in production since Prisma is backend-only | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - **pages/p/[id]/index.tsx**: Admin dashboard (protected) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Full CRUD operations for books | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Uses dynamic route with server-side validation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Only accessible at `/p/{ADMIN_PATH}` where path matches env var | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - **pages/p/[id]/login.tsx**: Admin login page | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Password-based authentication | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Sets HTTP-only cookie on successful login | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Components | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -83,6 +87,8 @@ Add new domains to `next.config.mjs` remotePatterns if needed. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Required in `.env`: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - `DATABASE_URL`: PostgreSQL connection string for Prisma | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - `ADMIN_PATH`: Secret URL path segment for admin access (required, no default) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - `ADMIN_PASSWORD`: Password for admin authentication | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ## Key Implementation Patterns | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -103,10 +109,24 @@ The date picker implementation: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Closes after both dates selected or picker cleared | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Shows all books when date range is not fully set | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Book/Author Upsert Logic | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ### Admin Security (Path Obfuscation) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The admin interface uses a multi-layered security approach: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1. **Secret URL Path**: The admin is only accessible at `/p/{ADMIN_PATH}` where `ADMIN_PATH` is an environment variable. There are no hardcoded paths like `/admin` in the codebase. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 2. **Dynamic Route Validation**: Pages at `pages/p/[id]/` use `getServerSideProps` to validate the `id` parameter matches `ADMIN_PATH`. Non-matching paths return 404. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 3. **API Route Validation**: API endpoints at `pages/api/p/[id]/` validate the path segment before processing requests. Invalid paths return 404. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 4. **Password Authentication**: Even with the correct path, users must authenticate with `ADMIN_PASSWORD` via a login form. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Scanning the codebase reveals nothing about the admin URL | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Brute-forcing `/p/{random}` returns 404 for incorrect guesses | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - API enumeration doesn't reveal admin-related endpoints | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Even finding the path requires knowing the password | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+112
to
131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security documentation is incomplete. The security section provides good coverage of path obfuscation but omits several critical security features mentioned in the PR objectives:
These implementation details are essential for understanding the full security model. 📝 Suggested additions to security documentation 5. **HTTP-only Cookies**: Auth state is stored in HTTP-only cookies to prevent XSS attacks.
+6. **Token-based Authentication**: Authentication uses HMAC-SHA256 signed tokens with 24-hour expiry. Tokens are validated using `crypto.timingSafeEqual` to prevent timing attacks.
+
+7. **Middleware Protection**: `middleware.ts` provides the first layer of defense, enforcing authentication on all `/p/:id*` routes using the Web Crypto API (Edge-compatible).
+
+8. **Defense-in-Depth**: Authentication is validated at multiple layers:
+ - Middleware (route-level protection)
+ - `getServerSideProps` (server-side page rendering)
+ - API route handlers (endpoint-level validation)
+
**Why this approach**: The source code is public, so we can't hardcode secret paths. By using environment variables and dynamic routes with server-side validation:📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The `upsertBooksAndAuthors` function in `pages/admin.tsx`: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Upserts books by title | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Upserts authors by name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Creates many-to-many relationships using Prisma's `connect` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| - Runs sequentially (not transactional) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **Important**: `ADMIN_PATH` has no fallback value. The app will throw an error if it's not set, ensuring the admin cannot accidentally be exposed at a default path. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,100 @@ | ||
| This is a starter template for [Learn Next.js](https://nextjs.org/learn). | ||
| # Half-Baked | ||
|
|
||
| A personal book collection app that displays books on a visual 3D bookshelf. Built with Next.js, Prisma, and PostgreSQL. | ||
|
|
||
| ## Features | ||
|
|
||
| - Visual bookshelf display with 3D shelf effects | ||
| - Filter books by date range | ||
| - Responsive layout that adapts to screen width | ||
| - Admin dashboard for managing books (CRUD operations) | ||
| - Protected admin access with password authentication | ||
|
|
||
| ## Tech Stack | ||
|
|
||
| - **Framework**: Next.js (Pages Router) | ||
| - **Database**: PostgreSQL with Prisma ORM | ||
| - **Styling**: Tailwind CSS + SCSS modules | ||
| - **Hosting**: Vercel + Supabase | ||
|
|
||
| ## Getting Started | ||
|
|
||
| ### Prerequisites | ||
|
|
||
| - Node.js 20.x or later | ||
| - PostgreSQL database (or Supabase account) | ||
|
|
||
| ### Environment Variables | ||
|
|
||
| Create a `.env` file in the root directory: | ||
|
|
||
| ```env | ||
| DATABASE_URL="postgresql://user:password@host:port/database" | ||
| ADMIN_PATH="your-secret-path" | ||
| ADMIN_PASSWORD="your-secure-password" | ||
| ``` | ||
|
|
||
| - `DATABASE_URL`: PostgreSQL connection string | ||
| - `ADMIN_PATH`: Secret URL path segment for admin access (e.g., `x7k9m2p4`) | ||
| - `ADMIN_PASSWORD`: Password for admin authentication | ||
|
|
||
| ### Installation | ||
|
|
||
| ```bash | ||
| # Install dependencies | ||
| npm install | ||
|
|
||
| # Generate Prisma client | ||
| npx prisma generate | ||
|
|
||
| # Run database migrations (if using migrations) | ||
| npx prisma migrate dev | ||
|
|
||
| # Or sync schema directly (for existing databases) | ||
| npx prisma db push | ||
| ``` | ||
|
|
||
| ### Development | ||
|
|
||
| ```bash | ||
| npm run dev | ||
| ``` | ||
|
|
||
| Open [http://localhost:3000](http://localhost:3000) to view the bookshelf. | ||
|
|
||
| ### Admin Access | ||
|
|
||
| The admin dashboard is accessible at `/p/{ADMIN_PATH}` where `{ADMIN_PATH}` is the value of your `ADMIN_PATH` environment variable. | ||
|
|
||
| Example: If `ADMIN_PATH=x7k9m2p4`, access admin at `http://localhost:3000/p/x7k9m2p4` | ||
|
|
||
| ### Production Build | ||
|
|
||
| ```bash | ||
| npm run build | ||
| npm run start | ||
| ``` | ||
|
|
||
| ## Project Structure | ||
|
|
||
| ``` | ||
| pages/ | ||
| ├── index.tsx # Main bookshelf page | ||
| ├── p/[id]/ | ||
| │ ├── index.tsx # Admin dashboard | ||
| │ └── login.tsx # Admin login | ||
| ├── api/ | ||
| │ ├── books/ # Book CRUD endpoints | ||
| │ └── p/[id]/ # Auth endpoints | ||
| components/ | ||
| ├── Book/ # Book display component | ||
| └── Shelf/ # 3D shelf component | ||
| lib/ | ||
| └── prisma.ts # Prisma client | ||
| prisma/ | ||
| └── schema.prisma # Database schema | ||
| ``` | ||
|
|
||
| ## License | ||
|
|
||
| Private project. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||||||||||||||
| import crypto from 'crypto'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export const AUTH_COOKIE_NAME = 'admin_auth'; | ||||||||||||||||||||||
| export const COOKIE_MAX_AGE = 60 * 60 * 24; // 24 hours in seconds | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Generates a signed authentication token using HMAC-SHA256. | ||||||||||||||||||||||
| * The token is based on a timestamp, making it unique per session. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function generateAuthToken(): string { | ||||||||||||||||||||||
| const secret = process.env.ADMIN_PASSWORD; | ||||||||||||||||||||||
| if (!secret) { | ||||||||||||||||||||||
| throw new Error('ADMIN_PASSWORD environment variable is not set'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const timestamp = Date.now().toString(); | ||||||||||||||||||||||
| const hmac = crypto.createHmac('sha256', secret); | ||||||||||||||||||||||
| hmac.update(timestamp); | ||||||||||||||||||||||
| const signature = hmac.digest('hex'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Format: timestamp.signature | ||||||||||||||||||||||
| return `${timestamp}.${signature}`; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Validates an authentication token using timing-safe comparison. | ||||||||||||||||||||||
| * Returns true if the token is valid and not expired. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function validateAuthToken(token: string | undefined): boolean { | ||||||||||||||||||||||
| if (!token) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const secret = process.env.ADMIN_PASSWORD; | ||||||||||||||||||||||
| if (!secret) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const parts = token.split('.'); | ||||||||||||||||||||||
| if (parts.length !== 2) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const [timestamp, providedSignature] = parts; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Check if token is expired (older than COOKIE_MAX_AGE) | ||||||||||||||||||||||
| const tokenAge = Date.now() - parseInt(timestamp, 10); | ||||||||||||||||||||||
| if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+46
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject tokens with future timestamps. The token age validation only checks if the token is too old, but doesn't reject tokens with future timestamps. A token with a timestamp in the future would have a negative 🛠️ Proposed fix // Check if token is expired (older than COOKIE_MAX_AGE)
const tokenAge = Date.now() - parseInt(timestamp, 10);
- if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) {
+ if (isNaN(tokenAge) || tokenAge < 0 || tokenAge > COOKIE_MAX_AGE * 1000) {
return false;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Regenerate the expected signature | ||||||||||||||||||||||
| const hmac = crypto.createHmac('sha256', secret); | ||||||||||||||||||||||
| hmac.update(timestamp); | ||||||||||||||||||||||
| const expectedSignature = hmac.digest('hex'); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Use timing-safe comparison to prevent timing attacks | ||||||||||||||||||||||
| return timingSafeEqual(providedSignature, expectedSignature); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Timing-safe string comparison to prevent timing attacks. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function timingSafeEqual(a: string, b: string): boolean { | ||||||||||||||||||||||
| const encoder = new TextEncoder(); | ||||||||||||||||||||||
| const bufA = encoder.encode(a); | ||||||||||||||||||||||
| const bufB = encoder.encode(b); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // If lengths differ, compare with itself to maintain constant time | ||||||||||||||||||||||
| // but return false | ||||||||||||||||||||||
| if (bufA.length !== bufB.length) { | ||||||||||||||||||||||
| crypto.timingSafeEqual(bufA, bufA); | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return crypto.timingSafeEqual(bufA, bufB); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Validates password using timing-safe comparison. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function validatePassword(password: string): boolean { | ||||||||||||||||||||||
| const adminPassword = process.env.ADMIN_PASSWORD; | ||||||||||||||||||||||
| if (!adminPassword || !password) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return timingSafeEqual(password, adminPassword); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Generates the Set-Cookie header value for authentication. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function generateAuthCookie(token: string): string { | ||||||||||||||||||||||
| const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; | ||||||||||||||||||||||
| return `${AUTH_COOKIE_NAME}=${token}; HttpOnly; Path=/; Max-Age=${COOKIE_MAX_AGE}; SameSite=Strict${secure}`; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Generates the Set-Cookie header value to clear authentication. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function generateClearAuthCookie(): string { | ||||||||||||||||||||||
| const secure = process.env.NODE_ENV === 'production' ? '; Secure' : ''; | ||||||||||||||||||||||
| return `${AUTH_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict${secure}`; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Validates authentication from API request cookies. | ||||||||||||||||||||||
| * Returns true if the request has a valid auth token. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function isAuthenticated(cookies: Partial<{ [key: string]: string }>): boolean { | ||||||||||||||||||||||
| const token = cookies[AUTH_COOKIE_NAME]; | ||||||||||||||||||||||
| return validateAuthToken(token); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||||||||||||||||||||
| import { NextResponse } from 'next/server'; | ||||||||||||||||||||||||
| import type { NextRequest } from 'next/server'; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const AUTH_COOKIE_NAME = 'admin_auth'; | ||||||||||||||||||||||||
| const COOKIE_MAX_AGE = 60 * 60 * 24; // 24 hours in seconds | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Validates an authentication token in Edge runtime. | ||||||||||||||||||||||||
| * Uses Web Crypto API which is available in Edge. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| async function validateAuthToken(token: string | undefined, secret: string): Promise<boolean> { | ||||||||||||||||||||||||
| if (!token || !secret) { | ||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const parts = token.split('.'); | ||||||||||||||||||||||||
| if (parts.length !== 2) { | ||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const [timestamp, providedSignature] = parts; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Check if token is expired (older than COOKIE_MAX_AGE) | ||||||||||||||||||||||||
| const tokenAge = Date.now() - parseInt(timestamp, 10); | ||||||||||||||||||||||||
| if (isNaN(tokenAge) || tokenAge > COOKIE_MAX_AGE * 1000) { | ||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Regenerate the expected signature using Web Crypto API | ||||||||||||||||||||||||
| const encoder = new TextEncoder(); | ||||||||||||||||||||||||
| const key = await crypto.subtle.importKey( | ||||||||||||||||||||||||
| 'raw', | ||||||||||||||||||||||||
| encoder.encode(secret), | ||||||||||||||||||||||||
| { name: 'HMAC', hash: 'SHA-256' }, | ||||||||||||||||||||||||
| false, | ||||||||||||||||||||||||
| ['sign'] | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(timestamp)); | ||||||||||||||||||||||||
| const expectedSignature = Array.from(new Uint8Array(signatureBuffer)) | ||||||||||||||||||||||||
| .map((b) => b.toString(16).padStart(2, '0')) | ||||||||||||||||||||||||
| .join(''); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Timing-safe comparison | ||||||||||||||||||||||||
| if (providedSignature.length !== expectedSignature.length) { | ||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| let result = 0; | ||||||||||||||||||||||||
| for (let i = 0; i < providedSignature.length; i++) { | ||||||||||||||||||||||||
| result |= providedSignature.charCodeAt(i) ^ expectedSignature.charCodeAt(i); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return result === 0; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export async function middleware(request: NextRequest) { | ||||||||||||||||||||||||
| const adminPath = process.env.ADMIN_PATH; | ||||||||||||||||||||||||
| const adminPassword = process.env.ADMIN_PASSWORD; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // If env vars not set, let the request through - pages will handle the error | ||||||||||||||||||||||||
| if (!adminPath || !adminPassword) { | ||||||||||||||||||||||||
| console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set'); | ||||||||||||||||||||||||
| return NextResponse.next(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+61
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security concern: Requests proceed when environment variables are missing. When Consider returning a 500 error or redirecting to a safe page instead of allowing the request to proceed. 🔒 Proposed fix // If env vars not set, let the request through - pages will handle the error
if (!adminPath || !adminPassword) {
console.error('ADMIN_PATH or ADMIN_PASSWORD environment variable is not set');
- return NextResponse.next();
+ // Return 500 to prevent exposing potentially misconfigured admin routes
+ return new NextResponse('Server configuration error', { status: 500 });
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { pathname } = request.nextUrl; | ||||||||||||||||||||||||
| const authCookie = request.cookies.get(AUTH_COOKIE_NAME); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Protected dashboard route: /p/${ADMIN_PATH} | ||||||||||||||||||||||||
| if (pathname === `/p/${adminPath}`) { | ||||||||||||||||||||||||
| const isValid = await validateAuthToken(authCookie?.value, adminPassword); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!isValid) { | ||||||||||||||||||||||||
| // Redirect to login page | ||||||||||||||||||||||||
| const loginUrl = new URL(`/p/${adminPath}/login`, request.url); | ||||||||||||||||||||||||
| return NextResponse.redirect(loginUrl); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // If already authenticated and trying to access login page, redirect to dashboard | ||||||||||||||||||||||||
| if (pathname === `/p/${adminPath}/login`) { | ||||||||||||||||||||||||
| const isValid = await validateAuthToken(authCookie?.value, adminPassword); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (isValid) { | ||||||||||||||||||||||||
| const dashboardUrl = new URL(`/p/${adminPath}`, request.url); | ||||||||||||||||||||||||
| return NextResponse.redirect(dashboardUrl); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return NextResponse.next(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export const config = { | ||||||||||||||||||||||||
| matcher: ['/p/:id*'], | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing required environment variable AUTH_SECRET.
The PR objectives explicitly state that AUTH_SECRET is a required environment variable for HMAC-SHA256 signed tokens. This critical configuration is not documented.
📝 Add AUTH_SECRET to documentation
Required in `.env`: - `DATABASE_URL`: PostgreSQL connection string for Prisma - `ADMIN_PATH`: Secret URL path segment for admin access (required, no default) - `ADMIN_PASSWORD`: Password for admin authentication +- `AUTH_SECRET`: Secret key for HMAC-SHA256 token signing (required for authentication)📝 Committable suggestion
🤖 Prompt for AI Agents