added basic authentication

This commit is contained in:
Gamosoft 2025-11-14 14:34:19 +01:00
parent c184b41a5b
commit e5e34152a0
10 changed files with 818 additions and 31 deletions

311
AUTHENTICATION.md Normal file
View File

@ -0,0 +1,311 @@
# 🔒 NoteDiscovery Authentication Guide
## Overview
NoteDiscovery includes a simple, secure authentication system for single-user deployments. When enabled, users must log in with a password before accessing the application.
## ✨ Features
- ✅ **Single User** - Perfect for personal/self-hosted use
- ✅ **Secure** - Passwords hashed with bcrypt
- ✅ **Session-based** - Stay logged in for 7 days (configurable)
- ✅ **Theme-aware** - Login page matches your selected theme
- ✅ **Simple Setup** - Just 3 steps to enable
---
## 🚀 Quick Setup
**⚠️ IMPORTANT:** Authentication is **disabled by default**. Follow these steps to enable it:
### Step 1: Generate a Password Hash
Choose one of these methods:
**Option A: Using Docker (Recommended - No local Python needed)**
If you're running NoteDiscovery in Docker:
```bash
# Docker Compose
docker-compose exec notediscovery /app/generate_password_hash.sh
# Or with docker run
docker exec -it notediscovery /app/generate_password_hash.sh
```
The script will prompt you for your password and output the hash.
**Option B: Using Local Python**
If you're running NoteDiscovery locally:
```bash
# Install bcrypt if not already installed
pip install bcrypt
# Run the password generator
python generate_password.py
```
Enter your desired password when prompted. The script will output a bcrypt hash.
**Copy the hash** - you'll need it for the next step.
### Step 2: Generate a Secret Key
Generate a random secret key for session encryption:
**Using Docker:**
```bash
docker-compose exec notediscovery python -c "import secrets; print(secrets.token_hex(32))"
# Or with docker run
docker exec -it notediscovery python -c "import secrets; print(secrets.token_hex(32))"
```
**Using Local Python:**
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
**Copy the key** - you'll need it for the next step.
### Step 3: Update `config.yaml`
Edit your `config.yaml` and update the security section:
```yaml
security:
# Enable authentication
enabled: true
# Session secret key (paste the output from Step 3)
secret_key: "your_generated_secret_key_here"
# Password hash (paste the output from Step 2)
password_hash: "$2b$12$..."
# Session expiry in seconds (7 days by default)
session_max_age: 604800
```
### Step 4: Restart the Application
```bash
# If running locally
uvicorn backend.main:app --reload
# If using Docker Compose
docker-compose restart
# Or with docker run
docker restart notediscovery
```
### Step 5: Test Login
Navigate to `http://localhost:8000` and you'll be redirected to the login page.
Enter the password you chose in Step 2.
---
## 🔐 Usage
### Logging In
1. Navigate to `http://localhost:8000`
2. You'll be **automatically redirected** to the login page if not authenticated
3. Enter your password (the one you generated in setup)
4. Click "🔓 Unlock"
5. You'll be redirected back to the main app
**Note:** The login page **automatically inherits the theme** you selected in the main app. It uses the same theme loading system as the main app (fetches CSS from `/api/themes` and stores preference in localStorage as `noteDiscoveryTheme`).
**What happens if I'm not logged in?**
- 🌐 **Page requests** (like `/`, `/MATHJAX`, etc.) → **Automatically redirected to login page**
- 🔌 **API requests** (like `/api/notes`) → **Return JSON error** `{"detail": "Not authenticated"}`
This smart routing ensures:
- ✅ Users always see the login page (never a JSON error)
- ✅ API calls get proper JSON errors (for programmatic access)
- ✅ No broken page loads or error messages
### Logging Out
**Option 1: Use the built-in logout button**
A logout button (🔒 Logout) automatically appears in the sidebar header when authentication is enabled. Simply click it to log out.
**Option 2: Navigate directly**
Visit `http://localhost:8000/logout` in your browser.
### Changing Password
1. Generate a new password hash:
- **Docker**: `docker-compose exec notediscovery /app/generate_password_hash.sh`
- **Local**: `python generate_password.py`
2. Update `password_hash` in `config.yaml` with the new hash
3. Restart the application:
- **Docker**: `docker-compose restart`
- **Local**: Restart uvicorn
4. All existing sessions will remain valid until they expire
### Session Expiry
- **Default**: 7 days (604800 seconds)
- **Custom**: Change `session_max_age` in `config.yaml`
- Sessions are cleared when you log out
---
## 🔒 Security Considerations
### ✅ What This Protects
- Unauthorized access to your notes
- Viewing, creating, editing, and deleting notes
- All API endpoints
### ⚠️ What This Doesn't Protect
This is a **simple authentication system** designed for **self-hosted, single-user** deployments. It is **NOT** suitable for:
- ❌ Multi-user environments
- ❌ Public internet exposure without HTTPS
- ❌ Production SaaS applications
- ❌ Compliance requirements (HIPAA, GDPR, etc.)
### 🛡️ Best Practices
1. **Use HTTPS** - Always run behind a reverse proxy (nginx, Caddy) with SSL/TLS
2. **Strong Password** - Use at least 12 characters with mixed case, numbers, and symbols
3. **Unique Secret Key** - Never reuse secret keys across applications
4. **Keep Config Secure** - Don't commit `config.yaml` with real credentials to version control
5. **VPN/Private Network** - Keep NoteDiscovery on a private network or behind a VPN
---
## 🚫 Disabling Authentication
To disable authentication and allow open access:
```yaml
security:
enabled: false
```
Restart the application, and authentication will be bypassed.
---
## 🐛 Troubleshooting
### "Invalid Password" Error
- **Check password hash**: Ensure the hash in `config.yaml` matches your password
- **Regenerate hash**: Run `python generate_password.py` again
- **Check encoding**: Password must be UTF-8 encoded
### "Not authenticated" Error
- **Check session**: Your session may have expired (default: 7 days)
- **Clear cookies**: Clear your browser cookies and log in again
- **Check config**: Ensure `enabled: true` in `config.yaml`
### Login Page Not Showing
- **Check config**: Verify `enabled: true` in `config.yaml`
- **Check routes**: Visit `/login` directly
- **Check logs**: Look for errors in the console
### Can't Access `/logout`
- You must be logged in to access `/logout`
- Clear your browser cookies manually if needed
---
## 🎨 Customizing Login Page
The login page (`frontend/login.html`) can be customized:
- **Theme**: Automatically inherits the theme you selected in the main app (from localStorage)
- **Logo**: Uses `/static/logo.svg`
- **Styling**: Edit the CSS in the `<style>` section
- **Content**: Modify the HTML directly
- **Error Messages**: Displayed inline in red when login fails (no separate error page)
**Note:** The theme selector is intentionally hidden on the login page to keep it clean. Users will see the theme they selected in the main app.
---
## 📦 Docker Deployment
When using Docker, mount your `config.yaml` with credentials:
```yaml
services:
notediscovery:
image: ghcr.io/yourusername/notediscovery:latest
volumes:
- ./config.yaml:/app/config.yaml
- ./data:/app/data
ports:
- "8000:8000"
```
**Security Note**: Don't build credentials into the Docker image. Always mount them as a volume.
---
## 🤝 Multi-User Support
This authentication system is designed for **single-user** deployments. If you need multi-user support:
1. Run separate instances (one per user)
2. Use Docker containers with different ports
3. Use a reverse proxy for routing
For enterprise/multi-user needs, consider:
- OAuth 2.0 / OpenID Connect
- Database-backed user management
- Role-based access control (RBAC)
---
## 📝 Technical Details
### Password Hashing
- **Algorithm**: bcrypt
- **Rounds**: 12 (default)
- **Salt**: Automatically generated per password
### Session Management
- **Storage**: Server-side session cookies
- **Signing**: HMAC with secret key
- **Security**: HttpOnly, SameSite=Lax
- **Expiry**: Configurable (default 7 days)
### Dependencies
- `bcrypt` - Password hashing
- `itsdangerous` - Session signing
- `starlette` - Session middleware
---
## 📚 Additional Resources
- [bcrypt Documentation](https://github.com/pyca/bcrypt/)
- [FastAPI Security](https://fastapi.tiangolo.com/tutorial/security/)
- [OWASP Authentication Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
---
**Need Help?** Open an issue on [GitHub](https://github.com/yourusername/notediscovery/issues) or consult the [README.md](README.md).

View File

@ -27,9 +27,11 @@ COPY frontend ./frontend
COPY config.yaml .
COPY plugins ./plugins
COPY themes ./themes
COPY generate_password_hash.sh .
# Create data directories
RUN mkdir -p data/notes data/search_index
# Create data directories and make password script executable
RUN mkdir -p data/notes data/search_index && \
chmod +x generate_password_hash.sh
# Expose port
EXPOSE 8000

View File

@ -32,6 +32,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put
### Key Benefits
- 🔒 **Total Privacy** - Your notes never leave your server
- 🔐 **Optional Authentication** - Simple password protection for self-hosted deployments
- 💰 **Zero Cost** - No subscriptions, no hidden fees
- 🚀 **Fast & Lightweight** - Instant search and navigation
- 🎨 **Beautiful Themes** - Multiple themes, easy to customize
@ -202,6 +203,7 @@ Once you've started NoteDiscovery, you'll find comprehensive guides on:
- 🧮 **MATHJAX.md** - LaTeX/Math notation examples and syntax reference
- 🔌 **PLUGINS.md** - Plugin system and available plugins
- 🌐 **API.md** - REST API documentation and examples
- 🔐 **AUTHENTICATION.md** - Enable password protection for your instance
**Can't wait to start the app?** Browse the documentation notes directly on GitHub in the [`data/notes/`](data/notes/) folder!
@ -217,15 +219,16 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
### Network Security
- ⚠️ **Do NOT expose directly to the internet** without additional security measures
- Run behind a reverse proxy (nginx, Caddy) with HTTPS and authentication if needed
- Run behind a reverse proxy (nginx, Caddy) with HTTPS for production use
- Keep it on your local network or use a VPN for remote access
- By default, the app listens on `0.0.0.0:8000` (all network interfaces)
### No Built-in Authentication
- The app has **no authentication by design** (single-user, self-hosted)
- Anyone with network access can read and modify your notes
- Use network-level security (firewall, VPN) for access control
- Consider adding authentication via reverse proxy if needed
### Authentication
- **Optional password protection** is available (disabled by default)
- See **[AUTHENTICATION.md](AUTHENTICATION.md)** for setup instructions
- Simple to enable with Docker: `docker-compose exec notediscovery /app/generate_password_hash.sh`
- Perfect for single-user or small team deployments
- For multi-user setups, consider a reverse proxy with OAuth/SSO
### Data Privacy
- Your notes are stored as **plain text markdown files** in `data/notes/`
@ -239,7 +242,7 @@ NoteDiscovery is designed for **self-hosted, private use**. Please keep these se
- Review and audit any plugins you install
- Set appropriate file permissions on the `data/` directory
**TL;DR**: Perfect for personal use on your local machine or home network. Add a reverse proxy with authentication if exposing to wider networks.
**TL;DR**: Perfect for personal use on your local machine or home network. Enable built-in password protection if needed, or use a reverse proxy with authentication if exposing to wider networks.
## 📄 License

View File

@ -3,10 +3,11 @@ NoteDiscovery - Self-Hosted Markdown Knowledge Base
Main FastAPI application
"""
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import os
import yaml
import json
@ -14,6 +15,7 @@ from pathlib import Path
from typing import List, Optional
import aiofiles
from datetime import datetime
import bcrypt
from .utils import (
get_all_notes,
@ -55,6 +57,15 @@ app.add_middleware(
allow_headers=["*"],
)
# Session middleware for authentication
app.add_middleware(
SessionMiddleware,
secret_key=config.get('security', {}).get('secret_key', 'insecure_default_key_change_this'),
max_age=config.get('security', {}).get('session_max_age', 604800), # 7 days default
same_site='lax',
https_only=False # Set to True if using HTTPS
)
# Ensure required directories exist
ensure_directories(config)
@ -69,8 +80,131 @@ static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static")
# ============================================================================
# Custom Exception Handlers
# ============================================================================
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
Custom exception handler for HTTP exceptions.
Handles 401 errors specially:
- For API requests: return JSON error
- For page requests: redirect to login
"""
# Only handle 401 errors specially
if exc.status_code == 401:
# Check if this is an API request
if request.url.path.startswith('/api/'):
return JSONResponse(
status_code=401,
content={"detail": exc.detail}
)
# For page requests, redirect to login
return RedirectResponse(url='/login', status_code=303)
# For all other HTTP exceptions, return default JSON response
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail}
)
# ============================================================================
# Authentication Helpers
# ============================================================================
def auth_enabled() -> bool:
"""Check if authentication is enabled in config"""
return config.get('security', {}).get('enabled', False)
async def require_auth(request: Request):
"""Dependency to require authentication on protected routes"""
if not auth_enabled():
return # Auth disabled, allow all
if not request.session.get('authenticated'):
# Always raise exception - route handlers will catch and redirect as needed
raise HTTPException(status_code=401, detail="Not authenticated")
def verify_password(password: str) -> bool:
"""Verify password against stored hash"""
password_hash = config.get('security', {}).get('password_hash', '')
if not password_hash:
return False
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e:
print(f"Password verification error: {e}")
return False
# ============================================================================
# Authentication Routes
# ============================================================================
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: str = None):
"""Serve the login page"""
if not auth_enabled():
return RedirectResponse(url="/", status_code=303)
# If already authenticated, redirect to home
if request.session.get('authenticated'):
return RedirectResponse(url="/", status_code=303)
# Serve login page
login_path = static_path / "login.html"
async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
content = await f.read()
# Inject error message if present
if error:
content = content.replace('<!-- ERROR_PLACEHOLDER -->',
f'<div class="error-message">❌ {error}</div>')
return content
@app.post("/login")
async def login(request: Request, password: str = Form(...)):
"""Handle login form submission"""
if not auth_enabled():
return RedirectResponse(url="/", status_code=303)
# Verify password
if verify_password(password):
# Set session
request.session['authenticated'] = True
return RedirectResponse(url="/", status_code=303)
else:
# Redirect back to login with error message
return RedirectResponse(url="/login?error=Invalid+password.+Please+try+again.", status_code=303)
@app.get("/logout")
async def logout(request: Request):
"""Log out the current user"""
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
@app.get("/favicon.ico")
async def favicon():
"""Serve favicon (redirect to SVG)"""
return RedirectResponse(url="/static/favicon.svg", status_code=301)
# ============================================================================
# Application Routes
# ============================================================================
@app.get("/", response_class=HTMLResponse)
async def root():
async def root(request: Request, _auth: None = Depends(require_auth)):
"""Serve the main application page"""
index_path = static_path / "index.html"
async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
@ -237,7 +371,10 @@ async def get_config():
"name": config['app']['name'],
"tagline": config['app']['tagline'],
"version": config['app']['version'],
"searchEnabled": config['search']['enabled']
"searchEnabled": config['search']['enabled'],
"security": {
"enabled": config.get('security', {}).get('enabled', False)
}
}
@ -262,7 +399,7 @@ async def get_theme(theme_id: str):
@app.post("/api/folders")
async def create_new_folder(data: dict):
async def create_new_folder(data: dict, _auth: None = Depends(require_auth)):
"""Create a new folder"""
try:
folder_path = data.get('path', '')
@ -284,7 +421,7 @@ async def create_new_folder(data: dict):
@app.post("/api/notes/move")
async def move_note_endpoint(data: dict):
async def move_note_endpoint(data: dict, _auth: None = Depends(require_auth)):
"""Move a note to a different folder"""
try:
old_path = data.get('oldPath', '')
@ -312,7 +449,7 @@ async def move_note_endpoint(data: dict):
@app.post("/api/folders/move")
async def move_folder_endpoint(data: dict):
async def move_folder_endpoint(data: dict, _auth: None = Depends(require_auth)):
"""Move a folder to a different location"""
try:
old_path = data.get('oldPath', '')
@ -337,7 +474,7 @@ async def move_folder_endpoint(data: dict):
@app.post("/api/folders/rename")
async def rename_folder_endpoint(data: dict):
async def rename_folder_endpoint(data: dict, _auth: None = Depends(require_auth)):
"""Rename a folder"""
try:
old_path = data.get('oldPath', '')
@ -362,7 +499,7 @@ async def rename_folder_endpoint(data: dict):
@app.delete("/api/folders/{folder_path:path}")
async def delete_folder_endpoint(folder_path: str):
async def delete_folder_endpoint(folder_path: str, _auth: None = Depends(require_auth)):
"""Delete a folder and all its contents"""
try:
if not folder_path:
@ -383,7 +520,7 @@ async def delete_folder_endpoint(folder_path: str):
@app.get("/api/notes")
async def list_notes():
async def list_notes(_auth: None = Depends(require_auth)):
"""List all notes with metadata"""
try:
notes = get_all_notes(config['storage']['notes_dir'])
@ -394,7 +531,7 @@ async def list_notes():
@app.get("/api/notes/{note_path:path}")
async def get_note(note_path: str):
async def get_note(note_path: str, _auth: None = Depends(require_auth)):
"""Get a specific note's content"""
try:
content = get_note_content(config['storage']['notes_dir'], note_path)
@ -422,7 +559,7 @@ async def get_note(note_path: str):
@app.post("/api/notes/{note_path:path}")
async def create_or_update_note(note_path: str, content: dict):
async def create_or_update_note(note_path: str, content: dict, _auth: None = Depends(require_auth)):
"""Create or update a note"""
try:
note_content = content.get('content', '')
@ -460,7 +597,7 @@ async def create_or_update_note(note_path: str, content: dict):
@app.delete("/api/notes/{note_path:path}")
async def remove_note(note_path: str):
async def remove_note(note_path: str, _auth: None = Depends(require_auth)):
"""Delete a note"""
try:
success = delete_note(config['storage']['notes_dir'], note_path)
@ -480,7 +617,7 @@ async def remove_note(note_path: str):
@app.get("/api/search")
async def search(q: str):
async def search(q: str, _auth: None = Depends(require_auth)):
"""Search notes by content"""
try:
if not config['search']['enabled']:
@ -499,7 +636,7 @@ async def search(q: str):
@app.get("/api/graph")
async def get_graph():
async def get_graph(_auth: None = Depends(require_auth)):
"""Get graph data for visualization"""
try:
notes = get_all_notes(config['storage']['notes_dir'])
@ -529,13 +666,13 @@ async def get_graph():
@app.get("/api/plugins")
async def list_plugins():
async def list_plugins(_auth: None = Depends(require_auth)):
"""List all available plugins"""
return {"plugins": plugin_manager.list_plugins()}
@app.get("/api/plugins/note_stats/calculate")
async def calculate_note_stats(content: str):
async def calculate_note_stats(content: str, _auth: None = Depends(require_auth)):
"""Calculate statistics for note content (if plugin enabled)"""
try:
plugin = plugin_manager.plugins.get('note_stats')
@ -549,7 +686,7 @@ async def calculate_note_stats(content: str):
@app.post("/api/plugins/{plugin_name}/toggle")
async def toggle_plugin(plugin_name: str, enabled: dict):
async def toggle_plugin(plugin_name: str, enabled: dict, _auth: None = Depends(require_auth)):
"""Enable or disable a plugin"""
try:
is_enabled = enabled.get('enabled', False)
@ -580,7 +717,7 @@ async def health_check():
# Catch-all route for SPA (Single Page Application) routing
# This allows URLs like /folder/note to work for direct navigation
@app.get("/{full_path:path}", response_class=HTMLResponse)
async def catch_all(full_path: str):
async def catch_all(full_path: str, request: Request, _auth: None = Depends(require_auth)):
"""
Serve index.html for all non-API routes.
This enables client-side routing (e.g., /folder/note)

View File

@ -20,6 +20,24 @@ search:
index_dir: "./data/search_index"
security:
# Add authentication later if needed
require_auth: false
# Authentication settings
# Set enabled to true to require login
enabled: true
# Session secret key - CHANGE THIS TO A RANDOM STRING!
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
secret_key: "change_this_to_a_random_secret_key_in_production"
# Password hash - Generate with: python generate_password.py
# INSTRUCTIONS:
# 1. Run: pip install bcrypt
# 2. Run: python generate_password.py
# 3. Enter your desired password
# 4. Copy the generated hash below
# 5. Set enabled: true above
# 6. Restart the application
password_hash: "$2b$12$t/6PGExFzdpU2PUta0iVY.eDQwvu63kH.c/d4bEnnHaQ5CspH1yrG"
# Session expiry in seconds (default: 7 days)
session_max_age: 604800

View File

@ -702,7 +702,20 @@
</template>
</select>
</div>
<p class="text-xs" style="color: var(--text-secondary);" x-text="appTagline"></p>
<p class="text-xs mb-3" style="color: var(--text-secondary);" x-text="appTagline"></p>
<!-- Logout Button (only show if auth is enabled) -->
<div x-data="{ authEnabled: false }" x-init="fetch('/api/config').then(r => r.json()).then(d => authEnabled = d.security?.enabled || false)">
<a x-show="authEnabled"
href="/logout"
class="flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors"
style="background-color: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'; this.style.color='var(--text-primary)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'; this.style.color='var(--text-secondary)'">
<span>🔒</span>
<span>Logout</span>
</a>
</div>
</div>
<!-- Search -->

181
frontend/login.html Normal file
View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NoteDiscovery</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- Theme styles will be loaded dynamically -->
<script>
// Load theme immediately (same approach as index.html)
(async function() {
const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light';
try {
const response = await fetch(`/api/themes/${savedTheme}`);
const data = await response.json();
// Create style element with theme CSS
const styleEl = document.createElement('style');
styleEl.id = 'dynamic-theme';
styleEl.textContent = data.css;
document.head.appendChild(styleEl);
// Set data attribute for theme-specific selectors
document.documentElement.setAttribute('data-theme', savedTheme);
} catch (error) {
console.error('Failed to load theme:', error);
}
})();
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
transition: background-color 0.3s ease, color 0.3s ease;
}
.login-container {
background-color: var(--bg-secondary);
padding: 3rem 2rem;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 400px;
text-align: center;
border: 1px solid var(--border-primary);
}
.logo {
width: 64px;
height: 64px;
margin: 0 auto 1.5rem;
}
h1 {
color: var(--text-primary);
font-size: 1.75rem;
margin-bottom: 0.5rem;
}
.tagline {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 2rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
input[type="password"] {
width: 100%;
padding: 0.875rem 1rem;
border: 2px solid var(--border-primary);
background-color: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s;
outline: none;
}
input[type="password"]:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-light, rgba(102, 126, 234, 0.1));
}
button {
width: 100%;
padding: 0.875rem 1rem;
background-color: var(--accent-primary);
color: var(--bg-primary);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.3s;
}
button:hover {
background-color: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--accent-light);
}
button:active {
transform: translateY(0);
}
.error-message {
background-color: #fee;
color: #c53030;
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-bottom: 1rem;
border: 1px solid #fcc;
text-align: left;
}
.footer {
margin-top: 2rem;
color: var(--text-tertiary, var(--text-secondary));
font-size: 0.75rem;
}
/* Mobile responsive */
@media (max-width: 480px) {
.login-container {
padding: 2rem 1.5rem;
}
h1 {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="login-container">
<img src="/static/logo.svg" alt="NoteDiscovery" class="logo">
<h1>NoteDiscovery</h1>
<p class="tagline">Your Self-Hosted Knowledge Base</p>
<!-- ERROR_PLACEHOLDER -->
<form method="post" action="/login">
<input
type="password"
name="password"
placeholder="Enter your password"
required
autofocus
>
<button type="submit">🔓 Unlock</button>
</form>
<div class="footer">
🔒 Secure & Self-Hosted
</div>
</div>
</body>
</html>

61
generate_password.py Normal file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Helper script to generate a password hash for NoteDiscovery authentication.
Usage: python generate_password.py
"""
import getpass
import bcrypt
def generate_password_hash():
"""Generate a bcrypt password hash"""
print("=== NoteDiscovery Password Hash Generator ===\n")
# Get password from user (hidden input)
password = getpass.getpass("Enter your password: ")
password_confirm = getpass.getpass("Confirm your password: ")
# Check if passwords match
if password != password_confirm:
print("\n❌ Error: Passwords do not match!")
return
if len(password) < 4:
print("\n⚠️ Warning: Password is too short! Use at least 8 characters for better security.")
proceed = input("Continue anyway? (y/N): ")
if proceed.lower() != 'y':
return
# Generate hash
print("\n🔐 Generating password hash...")
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
print("\n✅ Password hash generated successfully!")
print("\n" + "="*60)
print("Copy this hash to your config.yaml:")
print("="*60)
print(f"\npassword_hash: \"{password_hash}\"")
print("\n" + "="*60)
print("\nExample config.yaml:")
print("="*60)
print("""
security:
enabled: true
secret_key: "your_secret_key_here"
password_hash: "{}"
session_max_age: 604800
""".format(password_hash))
print("="*60)
print("\n💡 Don't forget to also set a secret key!")
print(" Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\"")
print("\n🔒 Keep your password and secret key secure!")
if __name__ == "__main__":
try:
generate_password_hash()
except KeyboardInterrupt:
print("\n\n❌ Cancelled.")
except Exception as e:
print(f"\n❌ Error: {e}")

59
generate_password_hash.sh Normal file
View File

@ -0,0 +1,59 @@
#!/bin/bash
# Generate password hash inside Docker container
# Usage: docker exec -it notediscovery /app/generate_password_hash.sh
echo "=========================================="
echo " NoteDiscovery Password Hash Generator"
echo "=========================================="
echo ""
echo "This will generate a bcrypt hash for your password."
echo ""
# Prompt for password
read -s -p "Enter your password: " PASSWORD
echo ""
read -s -p "Confirm password: " PASSWORD2
echo ""
# Check if passwords match
if [ "$PASSWORD" != "$PASSWORD2" ]; then
echo ""
echo "❌ Passwords do not match!"
exit 1
fi
# Check if password is empty
if [ -z "$PASSWORD" ]; then
echo ""
echo "❌ Password cannot be empty!"
exit 1
fi
echo ""
echo "Generating hash..."
echo ""
# Generate hash using Python
HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'$PASSWORD', bcrypt.gensalt()).decode())")
if [ $? -eq 0 ]; then
echo "=========================================="
echo " ✅ Password hash generated!"
echo "=========================================="
echo ""
echo "Copy this hash to your config.yaml:"
echo ""
echo "password_hash: \"$HASH\""
echo ""
echo "=========================================="
echo ""
echo "Next steps:"
echo "1. Update config.yaml with the hash above"
echo "2. Set enabled: true in the security section"
echo "3. Restart the container: docker-compose restart"
echo "=========================================="
else
echo "❌ Failed to generate hash"
exit 1
fi

View File

@ -5,4 +5,6 @@ markdown==3.5.1
pyyaml==6.0.1
aiofiles==23.2.1
cryptography==41.0.7
bcrypt==4.1.2
itsdangerous==2.1.2