Skip to content

Add Basic Auth Support #1089

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ khoj_assistant.egg-info
# ---
# npm
node_modules
package-lock.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unnecessary. We use yarn to manage packages for the web app. So there shouldn't be a package-lock.json only a yarn.lock (which does need to be checked in for reproducible builds)


# Don't include the compiled obsidian main.js file in the repo.
# They should be uploaded to GitHub releases instead.
Expand Down
38 changes: 30 additions & 8 deletions Dockerfile
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should revert changes to this file, they seem tangential to this PR

Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,40 @@ WORKDIR /app
COPY pyproject.toml .
COPY README.md .
ARG VERSION=0.0.0
# use the pre-built llama-cpp-python, torch cpu wheel

# Install dependencies
ENV PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/cpu https://abetlen.github.io/llama-cpp-python/whl/cpu"
# avoid downloading unused cuda specific python packages
ENV CUDA_VISIBLE_DEVICES=""
RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.toml && \

# First install core dependencies
RUN pip install --no-cache-dir pip==24.0 && \
pip install --no-cache-dir \
django==5.0.10 \
fastapi==0.115.6 \
uvicorn==0.30.6 \
pydantic==2.10.5 \
starlette==0.41.3
Comment on lines +37 to +41
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear why django and other package dependencies are being installed here instead of in pyproject.toml which also installs these dependencies


# Then install the package
RUN sed -i "s/dynamic = \[\"version\"\]/version = \"$VERSION\"/" pyproject.toml && \
pip install --no-cache-dir .

# Build Web App
FROM node:20-alpine AS web-app
FROM node:20-bullseye AS web-app
# Set build optimization env vars
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/src/interface/web

# Configure yarn for better network resilience
RUN yarn config set network-timeout 300000 && \
yarn config set network-concurrency 1 && \
yarn config set retry-number 5

# Install dependencies first (cache layer)
COPY src/interface/web/package.json src/interface/web/yarn.lock ./
RUN yarn install --frozen-lockfile
RUN yarn install --frozen-lockfile --network-timeout 300000 --network-concurrency 1

# Copy source and build
COPY src/interface/web/. ./
RUN yarn build
Expand All @@ -50,14 +68,18 @@ RUN yarn build
FROM base
ENV PYTHONPATH=/app/src
WORKDIR /app

# Copy Python packages and web build
COPY --from=server-deps /usr/local/lib/python3.10/dist-packages /usr/local/lib/python3.10/dist-packages
COPY --from=web-app /app/src/interface/web/out ./src/khoj/interface/built

# Copy source code
COPY . .
RUN cd src && python3 khoj/manage.py collectstatic --noinput

# Collect static files
RUN cd src && python3 -m pip install django==5.0.10 && python3 khoj/manage.py collectstatic --noinput

# Run the Application
# There are more arguments required for the application to run,
# but those should be passed in through the docker-compose.yml file.
ARG PORT=42110
EXPOSE ${PORT}
ENTRYPOINT ["python3", "src/khoj/main.py"]
15 changes: 11 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ services:
database:
condition: service_healthy
# Use the following line to use the latest version of khoj. Otherwise, it will build from source. Set this to ghcr.io/khoj-ai/khoj-cloud:latest if you want to use the prod image.
image: ghcr.io/khoj-ai/khoj:latest
# image: ghcr.io/khoj-ai/khoj:latest
# Uncomment the following line to build from source. This will take a few minutes. Comment the next two lines out if you want to use the official image.
# build:
# context: .
build:
context: .
dockerfile: Dockerfile
Comment on lines -30 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given context: . defaults to dockerfile: Dockerfile, the new dockerfile: Dockerfile line shouldn't be necessary

Comment on lines +30 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be commented out by default and the image: config above should be uncommented, so folks can use the pre-built image (instead of having to build the docker image locally)

ports:
# If changing the local port (left hand side), no other changes required.
# If changing the remote port (right hand side),
Expand All @@ -44,15 +45,19 @@ services:
- khoj_models:/root/.cache/huggingface
# Use 0.0.0.0 to explicitly set the host ip for the service on the container. https://pythonspeed.com/articles/docker-connection-refused/
environment:
# Database config
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=database
- POSTGRES_PORT=5432
# Django config
- KHOJ_DJANGO_SECRET_KEY=secret
- KHOJ_DEBUG=False
# Admin user
- [email protected]
- KHOJ_ADMIN_PASSWORD=password
# Service URLs
# Default URL of Terrarium, the Python sandbox used by Khoj to run code. Its container is specified above
- KHOJ_TERRARIUM_URL=http://sandbox:8080
# Default URL of SearxNG, the default web search engine used by Khoj. Its container is specified above
Expand Down Expand Up @@ -92,7 +97,9 @@ services:
# Read more at https://docs.khoj.dev/miscellaneous/telemetry
# - KHOJ_TELEMETRY_DISABLE=True
# Comment out this line when you're using the official ghcr.io/khoj-ai/khoj-cloud:latest prod image.
command: --host="0.0.0.0" --port=42110 -vv --anonymous-mode --non-interactive
entrypoint: python3 src/khoj/main.py --host=0.0.0.0 --port=42110 -vv --controlled-access --non-interactive
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can revert this be a command as the Dockerfile sets the ENTRYPOINT to python3 src/khoj/main.py already

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should default to anonymous mode for self-hosting users in my opinion. Multi-user setups for self-hosting users is not expected to be default

# entrypoint: python3 src/khoj/main.py --host=0.0.0.0 --port=42110 -vv --anonymous-mode --non-interactive
# entrypoint: python3 src/khoj/main.py --host=0.0.0.0 --port=42110 -vv --non-interactive
Comment on lines -95 to +102
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enabling basic auth via environment variable maybe better. That is by adding - ENABLE_CONTROLLED_ACCESS=True in the environment section for the Khoj service above


volumes:
khoj_config:
Expand Down
166 changes: 151 additions & 15 deletions src/interface/web/app/components/loginPrompt/loginPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
"use client";

import styles from "./loginPrompt.module.css";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Drawer, DrawerContent } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import Autoplay from "embla-carousel-autoplay";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import {
ArrowLeft,
ArrowsClockwise,
Expand All @@ -13,20 +21,12 @@ import {
PencilSimple,
Spinner,
} from "@phosphor-icons/react";
import Autoplay from "embla-carousel-autoplay";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { GoogleSignIn } from "./GoogleSignIn";
import { Drawer, DrawerContent } from "@/components/ui/drawer";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import styles from "./loginPrompt.module.css";

export interface LoginPromptProps {
onOpenChange: (open: boolean) => void;
Expand All @@ -48,13 +48,52 @@ interface CredentialsData {

export default function LoginPrompt(props: LoginPromptProps) {
const { data, error, isLoading } = useSWR<CredentialsData>("/auth/oauth/metadata", fetcher);

const [isBasicAuth, setIsBasicAuth] = useState(false);
const [useEmailSignIn, setUseEmailSignIn] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoggingIn, setIsLoggingIn] = useState(false);

const [email, setEmail] = useState("");
const [checkEmail, setCheckEmail] = useState(false);
const [recheckEmail, setRecheckEmail] = useState(false);

useEffect(() => {
// We're in basic auth mode if:
// 1. Data is empty object (no providers)
// 2. Data has google: null (controlled access mode)
if (!isLoading && (!data || Object.keys(data).length === 0 || data.google === null)) {
setIsBasicAuth(true);
}
}, [data, isLoading]);

const handleBasicLogin = async () => {
if (isLoggingIn || !username || !password) return;
setIsLoggingIn(true);
setLoginError("");
const formData = new FormData();
formData.append("username", username);
formData.append("password", password);

try {
const response = await fetch("/auth/login", {
method: "POST",
body: formData,
});

if (response.ok && response.redirected) {
window.location.href = response.url;
} else {
setLoginError("Invalid username or password");
}
} catch (err) {
setLoginError("Login failed. Please try again.");
} finally {
setIsLoggingIn(false);
}
};

useEffect(() => {
const google = (window as any).google;

Expand Down Expand Up @@ -144,12 +183,62 @@ export default function LoginPrompt(props: LoginPromptProps) {
});
}

// Basic auth form component
const BasicAuthForm = () => (
<div className="flex flex-col gap-4 p-4">
<div>
<div className="text-center font-bold text-xl">Sign in to Khoj</div>
</div>
<Input
placeholder="Username"
className="p-6 w-[300px] mx-auto rounded-lg"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
<Input
type="password"
placeholder="Password"
className="p-6 w-[300px] mx-auto rounded-lg"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
{loginError && <div className="text-red-500 text-sm text-center">{loginError}</div>}
<Button
variant="default"
className="p-6 w-[300px] mx-auto flex gap-2 items-center justify-center rounded-lg"
onClick={handleBasicLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? (
<>
<Spinner className="h-5 w-5 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</div>
);

if (props.isMobileWidth) {
return (
<Drawer open={true} onOpenChange={props.onOpenChange}>
<DrawerContent className={`flex flex-col gap-4 w-full mb-4`}>
<div>
{useEmailSignIn ? (
{isBasicAuth ? (
<BasicAuthForm />
) : useEmailSignIn ? (
<EmailSignInContext
email={email}
setEmail={setEmail}
Expand Down Expand Up @@ -182,7 +271,54 @@ export default function LoginPrompt(props: LoginPromptProps) {
className={`flex flex-col gap-4 ${!useEmailSignIn ? "p-0 pb-4 m-0 max-w-xl" : "w-fit"}`}
>
<div>
{useEmailSignIn ? (
{isBasicAuth ? (
<div className="flex flex-col gap-4 p-4">
<div>
<div className="text-center font-bold text-xl">Sign in to Khoj</div>
</div>
<Input
placeholder="Username"
className="p-6 w-[300px] mx-auto rounded-lg"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
<Input
type="password"
placeholder="Password"
className="p-6 w-[300px] mx-auto rounded-lg"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
{loginError && (
<div className="text-red-500 text-sm text-center">{loginError}</div>
)}
<Button
variant="default"
className="p-6 w-[300px] mx-auto flex gap-2 items-center justify-center rounded-lg"
onClick={handleBasicLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? (
<>
<Spinner className="h-5 w-5 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</div>
) : useEmailSignIn ? (
Comment on lines +275 to +321
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this just reuse BasicAuthForm component like

Suggested change
<div className="flex flex-col gap-4 p-4">
<div>
<div className="text-center font-bold text-xl">Sign in to Khoj</div>
</div>
<Input
placeholder="Username"
className="p-6 w-[300px] mx-auto rounded-lg"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
<Input
type="password"
placeholder="Password"
className="p-6 w-[300px] mx-auto rounded-lg"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBasicLogin();
}
}}
/>
{loginError && (
<div className="text-red-500 text-sm text-center">{loginError}</div>
)}
<Button
variant="default"
className="p-6 w-[300px] mx-auto flex gap-2 items-center justify-center rounded-lg"
onClick={handleBasicLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? (
<>
<Spinner className="h-5 w-5 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</div>
) : useEmailSignIn ? (
<BasicAuthForm />
) : useEmailSignIn ? (

<EmailSignInContext
email={email}
setEmail={setEmail}
Expand Down
Loading