From b205f0abb5a4e95f4b29ff0395dd9e98a97d3cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philip=20Dub=C3=A9?= Date: Fri, 24 Nov 2023 15:50:02 +0000 Subject: [PATCH] UI auth (#699) Implement the most primitive authentication scheme I could think of Extending this can be done in future by introducing new supporting environment variables If someone feels queasy about storing password in cookie, a 2nd env variable can be made, `PEERDB_PASSWORD_TOKEN`, which the cookie is set to instead of the password On cloud we'll want to use `pbkdf2` or some other key derivation function so that we never store the password anywhere. So add a `PEERDB_PASSWORD_FUNCTION` variable & set `PEERDB_PASSWORD` to `{algo:'SHA512',iter:999999,salt:'random',auth:'result'}` & have login handler hash password accordingly. Managed service would handle setting these environment variables on customer's setup --- docker-compose-dev.yml | 1 + ui/app/api/login/route.ts | 13 +++++++ ui/app/api/logout/route.ts | 6 +++ ui/app/login/page.tsx | 61 ++++++++++++++++++++++++++++++ ui/app/page.tsx | 3 +- ui/components/Logout.tsx | 16 ++++++++ ui/components/SidebarComponent.tsx | 6 +-- ui/middleware.ts | 23 +++++++++++ 8 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 ui/app/api/login/route.ts create mode 100644 ui/app/api/logout/route.ts create mode 100644 ui/app/login/page.tsx create mode 100644 ui/components/Logout.tsx create mode 100644 ui/middleware.ts diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 840bd556fc..bfe9633916 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -201,6 +201,7 @@ services: <<: *catalog-config DATABASE_URL: postgres://postgres:postgres@catalog:5432/postgres PEERDB_FLOW_SERVER_HTTP: http://flow_api:8113 + PEERDB_PASSWORD: peerdb volumes: pgdata: diff --git a/ui/app/api/login/route.ts b/ui/app/api/login/route.ts new file mode 100644 index 0000000000..9ae9b961e0 --- /dev/null +++ b/ui/app/api/login/route.ts @@ -0,0 +1,13 @@ +import { cookies } from 'next/headers'; + +export async function POST(request: Request) { + const { password } = await request.json(); + if (process.env.PEERDB_PASSWORD !== password) { + return new Response(JSON.stringify({ error: 'wrong password' })); + } + cookies().set('auth', password, { + expires: Date.now() + 24 * 60 * 60 * 1000, + secure: process.env.PEERDB_SECURE_COOKIES === 'true', + }); + return new Response('{}'); +} diff --git a/ui/app/api/logout/route.ts b/ui/app/api/logout/route.ts new file mode 100644 index 0000000000..5faf5a8c84 --- /dev/null +++ b/ui/app/api/logout/route.ts @@ -0,0 +1,6 @@ +import { cookies } from 'next/headers'; + +export async function POST(req: Request) { + cookies().delete('auth'); + return new Response(''); +} diff --git a/ui/app/login/page.tsx b/ui/app/login/page.tsx new file mode 100644 index 0000000000..d158978aab --- /dev/null +++ b/ui/app/login/page.tsx @@ -0,0 +1,61 @@ +'use client'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; + +import { Button } from '@/lib/Button'; +import { Layout, LayoutMain } from '@/lib/Layout'; +import { TextField } from '@/lib/TextField'; + +export default function Login() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [pass, setPass] = useState(''); + const [error, setError] = useState(() => + searchParams.has('reject') ? 'Authentication failed, please login' : '' + ); + const login = () => { + fetch('/api/login', { + method: 'POST', + body: JSON.stringify({ password: pass }), + }) + .then((res) => res.json()) + .then((res) => { + if (res.error) setError(res.error); + else router.push('/'); + }); + }; + return ( + + + PeerDB + {error && ( +
+ {error} +
+ )} + ) => + setPass(e.target.value) + } + onKeyPress={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + login(); + } + }} + /> + +
+
+ ); +} diff --git a/ui/app/page.tsx b/ui/app/page.tsx index 4e84221bb7..d89f981887 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -1,10 +1,11 @@ import SidebarComponent from '@/components/SidebarComponent'; import { Header } from '@/lib/Header'; import { Layout, LayoutMain } from '@/lib/Layout'; +import { cookies } from 'next/headers'; export default function Home() { return ( - }> + }>
PeerDB Home Page
diff --git a/ui/components/Logout.tsx b/ui/components/Logout.tsx new file mode 100644 index 0000000000..21c8e8267a --- /dev/null +++ b/ui/components/Logout.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Button } from '@/lib/Button'; + +export default function Logout() { + return ( + + ); +} diff --git a/ui/components/SidebarComponent.tsx b/ui/components/SidebarComponent.tsx index c60372da8e..c9afc54ca6 100644 --- a/ui/components/SidebarComponent.tsx +++ b/ui/components/SidebarComponent.tsx @@ -1,15 +1,15 @@ 'use client'; import useTZStore from '@/app/globalstate/time'; +import Logout from '@/components/Logout'; import { BrandLogo } from '@/lib/BrandLogo'; -import { Button } from '@/lib/Button'; import { Icon } from '@/lib/Icon'; import { Label } from '@/lib/Label'; import { RowWithSelect } from '@/lib/Layout'; import { Sidebar, SidebarItem } from '@/lib/Sidebar'; import Link from 'next/link'; -export default function SidebarComponent() { +export default function SidebarComponent(props: { logout?: boolean }) { const timezones = ['UTC', 'Local', 'Relative']; const setZone = useTZStore((state) => state.setZone); const zone = useTZStore((state) => state.timezone); @@ -60,7 +60,7 @@ export default function SidebarComponent() { /> - + {props.logout && } } bottomLabel={} diff --git a/ui/middleware.ts b/ui/middleware.ts new file mode 100644 index 0000000000..3f616b1a7a --- /dev/null +++ b/ui/middleware.ts @@ -0,0 +1,23 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +export default function middleware(req: NextRequest) { + if ( + req.nextUrl.pathname !== '/login' && + req.nextUrl.pathname !== '/api/login' && + req.nextUrl.pathname !== '/api/logout' && + process.env.PEERDB_PASSWORD && + req.cookies.get('auth')?.value !== process.env.PEERDB_PASSWORD + ) { + req.cookies.delete('auth'); + return NextResponse.redirect(new URL('/login?reject', req.url)); + } + return NextResponse.next(); +} + +export const config = { + matcher: [ + // Match everything other than static assets + '/((?!_next/static/|images/|favicon.ico$).*)', + ], +};