Add simple email/password authentication to frontend

Implemented optional authentication to keep strangers out of the
Claude OS frontend when deployed to production servers.

Backend (FastAPI):
- mcp_server/auth.py: JWT-based authentication
  * Bcrypt password hashing
  * Environment-based user credentials (no database needed)
  * 7-day token expiration
  * Optional - disabled by default

- mcp_server/server.py: Auth endpoints
  * POST /api/auth/login - Returns JWT token
  * GET /api/auth/me - Get current user
  * GET /api/auth/status - Check if auth enabled

- requirements.txt: Added dependencies
  * passlib[bcrypt] for password hashing
  * python-jose[cryptography] for JWT
  * python-multipart for form data

Frontend (React):
- frontend/src/lib/auth.tsx: Auth context and hooks
  * Manages authentication state
  * Automatic token validation
  * localStorage for token persistence

- frontend/src/pages/Login.tsx: Beautiful login page
  * Gradient design matching Claude OS branding
  * Clear error messages
  * Loading states

- frontend/src/components/ProtectedRoute.tsx: Route protection
  * Automatically redirects to login if not authenticated
  * Shows loading state during auth check
  * Bypasses auth if disabled

- frontend/src/App.tsx: Integrated auth
  * Wrapped app in AuthProvider
  * Protected routes for Welcome and MainApp
  * Login route for authentication

Documentation:
- AUTH_SETUP.md: Complete authentication guide
  * Quick start instructions
  * Password hash generation
  * Production deployment steps
  * Troubleshooting guide

- .env.example: Added auth configuration
  * CLAUDE_OS_EMAIL (login email)
  * CLAUDE_OS_PASSWORD (dev - plain text)
  * CLAUDE_OS_PASSWORD_HASH (production - bcrypt hash)
  * CLAUDE_OS_SECRET_KEY (JWT secret)

- docs/PRODUCTION_DEPLOYMENT.md: Added Step 5
  * Authentication setup for production
  * Hash and secret generation commands
  * Testing instructions

Features:
✅ Optional authentication (disabled by default)
✅ Single user via environment variables
✅ Secure bcrypt hashing
✅ JWT tokens with 7-day expiration
✅ Beautiful login UI
✅ No database required for user management

Usage:
1. Set CLAUDE_OS_EMAIL and CLAUDE_OS_PASSWORD in .env
2. Restart Claude OS
3. Visit /login to authenticate
4. Token valid for 7 days

To disable auth: Remove CLAUDE_OS_EMAIL from .env

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Bob Roberts 2025-10-30 21:07:53 -05:00
parent 0973601fec
commit 1b835c3a3e
10 changed files with 621 additions and 10 deletions

View file

@ -1,4 +1,25 @@
# ============================================================================
# Authentication (Optional - Leave empty to disable auth)
# ============================================================================
# Login email (required to enable auth)
CLAUDE_OS_EMAIL=admin@example.com
# Option 1: Plain password (development only - auto-hashed)
CLAUDE_OS_PASSWORD=your_secure_password_here
# Option 2: Pre-hashed password (production - recommended)
# Generate: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('your_password'))"
# CLAUDE_OS_PASSWORD_HASH=$2b$12$xyz...
# JWT secret key (auto-generated if not set)
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# CLAUDE_OS_SECRET_KEY=your-super-secret-key-min-32-chars
# ============================================================================
# Ollama Configuration
# ============================================================================
OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=llama3.1:latest
OLLAMA_EMBED_MODEL=nomic-embed-text

190
AUTH_SETUP.md Normal file
View file

@ -0,0 +1,190 @@
# Claude OS Authentication Setup
Simple email/password authentication to keep strangers out of your Claude OS frontend.
## Features
✅ **Optional Authentication** - Disabled by default, enable when needed
✅ **Environment-based** - No database required for user management
✅ **JWT Tokens** - 7-day token expiration
✅ **Secure** - Bcrypt password hashing
✅ **Simple** - Single user account via environment variables
## Quick Start
### 1. Enable Authentication
Set environment variables in your `.env` file or server environment:
```bash
# Required: Email for login
CLAUDE_OS_EMAIL=admin@example.com
# Option 1: Plain password (development only - will be hashed automatically)
CLAUDE_OS_PASSWORD=your_secure_password_here
# Option 2: Pre-hashed password (recommended for production)
CLAUDE_OS_PASSWORD_HASH=$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5hhA82jdg8jpu
# Optional: Custom secret key for JWT (auto-generated if not set)
CLAUDE_OS_SECRET_KEY=your-super-secret-key-min-32-chars
```
### 2. Generate Password Hash (Production)
For production, use a pre-hashed password instead of plain text:
```bash
# Install dependencies first
cd ~/Projects/claude-os
source venv/bin/activate
pip install -r requirements.txt
# Generate hash
python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('your_password_here'))"
```
Copy the output and use it as `CLAUDE_OS_PASSWORD_HASH` in your `.env` file.
### 3. Restart Claude OS
```bash
# Local (Mac)
./stop.sh
./start.sh
# Production Server
sudo systemctl restart claude-os
```
### 4. Login
Visit your Claude OS frontend:
- Local: http://localhost:5173/login
- Production: https://your-domain.com/login
Use the email and password you configured.
## Production Deployment
### Server Environment Variables
Add to `/opt/claude-os/.env`:
```bash
CLAUDE_OS_EMAIL=admin@pistn.com
CLAUDE_OS_PASSWORD_HASH=$2b$12$xyz... # Generated hash
CLAUDE_OS_SECRET_KEY=your-32-char-secret
```
Restart the service:
```bash
sudo systemctl restart claude-os
```
## Disable Authentication
To disable authentication (open access):
```bash
# Remove or comment out these variables in .env:
# CLAUDE_OS_EMAIL=...
# CLAUDE_OS_PASSWORD=...
# CLAUDE_OS_PASSWORD_HASH=...
```
Restart Claude OS. The frontend will allow access without login.
## How It Works
### Backend (FastAPI)
- **`/api/auth/login`** - Login endpoint, returns JWT token
- **`/api/auth/me`** - Get current user info
- **`/api/auth/status`** - Check if authentication is enabled
### Frontend (React)
- **Login Page** - Beautiful gradient login form
- **Auth Context** - Manages authentication state
- **Protected Routes** - Automatically redirects to login if not authenticated
- **Token Storage** - JWT stored in localStorage (7-day expiration)
### Security Features
✅ **Bcrypt hashing** - Industry-standard password encryption
✅ **JWT tokens** - Stateless authentication
✅ **Automatic expiration** - Tokens expire after 7 days
✅ **HTTPS recommended** - Use SSL in production
✅ **No database needed** - Single user, environment-based
## Troubleshooting
### "Authentication is not configured"
You haven't set the `CLAUDE_OS_EMAIL` environment variable. Set it and restart.
### "Incorrect email or password"
Check your `.env` file:
- Email matches exactly (case-sensitive)
- If using `CLAUDE_OS_PASSWORD`, make sure it's correct
- If using `CLAUDE_OS_PASSWORD_HASH`, regenerate the hash
### Login page doesn't appear
Make sure your Claude OS frontend is running:
```bash
# Local development
cd frontend
npm run dev
# Production (should be served by Nginx)
curl http://localhost:5173
```
### Token expired
Tokens last 7 days. Just login again to get a new token.
## Multiple Users
This system is designed for single-user access. For multiple users:
1. **Simple approach**: Share one account with your team
2. **Advanced approach**: Extend the auth system to use a database (requires custom implementation)
For most teams deploying Claude OS internally, a single shared account is sufficient since it's:
- Behind your firewall
- For trusted team members only
- Just to keep strangers out (not enterprise-grade auth)
## Security Best Practices
### Development (Local)
```bash
CLAUDE_OS_EMAIL=dev@localhost
CLAUDE_OS_PASSWORD=dev123
```
Fine for local development. Password is hashed automatically.
### Production (Server)
```bash
CLAUDE_OS_EMAIL=admin@yourcompany.com
CLAUDE_OS_PASSWORD_HASH=$2b$12$xyz... # Pre-hashed
CLAUDE_OS_SECRET_KEY=randomly-generated-32-char-secret
```
✅ **Use hashed passwords**
✅ **Use strong secret keys**
✅ **Use HTTPS/SSL**
✅ **Rotate passwords periodically**
---
**Questions?** Check the main Claude OS README or open an issue on GitHub.

View file

@ -371,12 +371,14 @@ your-project/
Agent-OS requires AI for advanced features:
**Option 1: Ollama (Local, Free)**
- Requires 8GB+ RAM
- Fully private, no data leaves your machine
- Install: `https://ollama.ai`
- Then: `ollama pull llama2`
**Option 2: OpenAI API (Cloud, Paid)**
- Works on any machine
- ~$0.02 per request
- Requires API key (set during `./install.sh`)
@ -829,11 +831,12 @@ Claude OS optionally integrates with [Agent-OS](https://github.com/builder-metho
- **Project**: Agent-OS - Spec-driven development workflow system
- **Author**: Builder Methods (CasJam Media LLC)
- **License**: MIT
- **Repository**: https://github.com/builder-methods/agent-os
- **Repository**: <https://github.com/builder-methods/agent-os>
Agent-OS provides 8 specialized agents for structured feature planning and implementation. We're grateful to Builder Methods for creating such powerful tools and for licensing them under MIT, making this integration possible.
If you find Agent-OS valuable, consider:
- ⭐ Starring their repository
- 📣 Sharing it with other developers
- 🤝 Contributing to their project

View file

@ -153,7 +153,54 @@ curl "http://localhost:8051/api/kb/list"
curl "http://localhost:8051/api/kb/Pistn-project_index/stats"
```
### 5. Update PISTN Rails App
### 5. Setup Authentication (Recommended for Production)
Protect the Claude OS frontend from strangers with simple email/password authentication.
**Add to `/opt/claude-os/.env`:**
```bash
# Authentication
CLAUDE_OS_EMAIL=admin@pistn.com
CLAUDE_OS_PASSWORD_HASH=<generated_hash>
CLAUDE_OS_SECRET_KEY=<random_32_char_secret>
```
**Generate password hash:**
```bash
# SSH into server
ssh deploy@staging.pistn.com
# Generate hash
cd /opt/claude-os
source venv/bin/activate
python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('your_secure_password'))"
# Copy the output and add to .env as CLAUDE_OS_PASSWORD_HASH
```
**Generate secret key:**
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```
**Restart Claude OS:**
```bash
sudo systemctl restart claude-os
```
**Test login:**
Visit `https://staging.pistn.com/login` and use your configured email/password.
**Note:** If you don't configure these variables, authentication is disabled and the frontend is open access.
See [`AUTH_SETUP.md`](/AUTH_SETUP.md) for detailed authentication documentation.
### 6. Update PISTN Rails App
**Add to `/var/www/pistn/shared/.env`:**
```bash
@ -211,7 +258,7 @@ end
cap production deploy
```
### 6. Test Integration
### 7. Test Integration
```bash
# Test Claude OS directly

View file

@ -1,7 +1,10 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './lib/auth';
import Welcome from './pages/Welcome';
import MainApp from './pages/MainApp';
import Login from './pages/Login';
import ProtectedRoute from './components/ProtectedRoute';
const queryClient = new QueryClient({
defaultOptions: {
@ -15,13 +18,24 @@ const queryClient = new QueryClient({
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Welcome />} />
<Route path="/app" element={<MainApp />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Welcome />
</ProtectedRoute>
} />
<Route path="/app" element={
<ProtectedRoute>
<MainApp />
</ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,31 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAuthEnabled, isLoading } = useAuth();
// Show loading state while checking auth
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-white text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
<p>Loading...</p>
</div>
</div>
);
}
// If auth is disabled, allow access
if (!isAuthEnabled) {
return <>{children}</>;
}
// If auth is enabled but user not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// User is authenticated, show the protected content
return <>{children}</>;
}

View file

@ -0,0 +1,99 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../lib/auth';
export default function Login() {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(email, password);
navigate('/app');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Claude OS</h1>
<p className="text-slate-300">AI-Assisted Development with Persistent Context</p>
</div>
<div className="bg-white/10 backdrop-blur-lg rounded-lg p-8 shadow-2xl border border-white/20">
<h2 className="text-2xl font-semibold text-white mb-6">Sign In</h2>
{error && (
<div className="mb-4 p-3 rounded bg-red-500/20 border border-red-500/50 text-red-200 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="admin@example.com"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="••••••••"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-purple-600 hover:bg-purple-700 disabled:bg-purple-800 disabled:cursor-not-allowed text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6 text-center text-sm text-slate-400">
<p>Authentication keeps strangers out.</p>
<p className="mt-2">Contact your administrator for access.</p>
</div>
</div>
<div className="mt-6 text-center text-xs text-slate-400">
<p>Powered by Claude OS • Secure & Private</p>
</div>
</div>
</div>
);
}

138
mcp_server/auth.py Normal file
View file

@ -0,0 +1,138 @@
"""
Simple authentication for Claude OS.
Uses environment variables for credentials - no database needed.
"""
import os
import secrets
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, Security, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from passlib.context import CryptContext
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.getenv("CLAUDE_OS_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
# Security
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def get_user_credentials() -> dict:
"""
Get user credentials from environment variables.
Set in .env or environment:
CLAUDE_OS_EMAIL=admin@example.com
CLAUDE_OS_PASSWORD_HASH=<bcrypt hash>
To generate a password hash:
python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('your_password'))"
"""
email = os.getenv("CLAUDE_OS_EMAIL")
password_hash = os.getenv("CLAUDE_OS_PASSWORD_HASH")
# For development, allow plain password (will be hashed on the fly)
password_plain = os.getenv("CLAUDE_OS_PASSWORD")
if not email:
# Default to disabled auth if not configured
return None
if password_hash:
return {"email": email, "password_hash": password_hash}
elif password_plain:
# Hash the plain password (for development only)
return {"email": email, "password_hash": get_password_hash(password_plain)}
else:
return None
def authenticate_user(email: str, password: str) -> bool:
"""Authenticate a user by email and password."""
creds = get_user_credentials()
if not creds:
# Auth disabled
return False
if email != creds["email"]:
return False
return verify_password(password, creds["password_hash"])
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT access token."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.PyJWTError:
return None
def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict:
"""
Dependency to get the current authenticated user.
Use in FastAPI routes: current_user = Depends(get_current_user)
"""
creds = get_user_credentials()
# If auth not configured, allow all requests
if not creds:
return {"email": "guest", "auth_disabled": True}
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
email = payload.get("sub")
if email is None:
raise HTTPException(
status_code=401,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return {"email": email}
def is_auth_enabled() -> bool:
"""Check if authentication is enabled."""
return get_user_credentials() is not None

View file

@ -1566,6 +1566,69 @@ async def handle_mcp_request(request: Request, kb_filter: Optional[str] = None)
})
# ============================================================================
# Authentication Endpoints
# ============================================================================
from mcp_server.auth import (
authenticate_user,
create_access_token,
is_auth_enabled,
get_current_user
)
from datetime import timedelta
class LoginRequest(BaseModel):
email: str
password: str
@app.post("/api/auth/login")
async def login(request: LoginRequest):
"""Login endpoint - returns JWT token if credentials are valid."""
if not is_auth_enabled():
raise HTTPException(
status_code=501,
detail="Authentication is not configured. Set CLAUDE_OS_EMAIL and CLAUDE_OS_PASSWORD in environment."
)
if not authenticate_user(request.email, request.password):
raise HTTPException(
status_code=401,
detail="Incorrect email or password"
)
access_token = create_access_token(
data={"sub": request.email},
expires_delta=timedelta(days=7)
)
return {
"access_token": access_token,
"token_type": "bearer",
"email": request.email
}
@app.get("/api/auth/me")
async def get_me(current_user: dict = Depends(get_current_user)):
"""Get current user info."""
return current_user
@app.get("/api/auth/status")
async def auth_status():
"""Check if authentication is enabled."""
return {
"auth_enabled": is_auth_enabled()
}
# ============================================================================
# Health Check
# ============================================================================
@app.get("/health")
async def health_check():
"""

View file

@ -23,6 +23,11 @@ fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.0.0
# Authentication
passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
# Utilities
python-dotenv>=1.0.0
requests>=2.31.0