fix: load configuration from config.json and fix authentication

- Load anime_directory and master_password_hash from config.json on startup
- Sync configuration from config.json to settings object in fastapi_app.py
- Update dependencies.py to load config from JSON if not in environment
- Fix app.js to use makeAuthenticatedRequest() for all authenticated API calls
- Fix API endpoint paths from /api/v1/anime to /api/anime
- Update auth_service.py to load master_password_hash from config.json
- Update auth.py setup endpoint to save master_password_hash to config
- Fix rate limiting code to satisfy type checker
- Update config.json with test master password hash

Fixes:
- 401 Unauthorized errors on /api/anime endpoint
- 503 Service Unavailable errors on /api/anime/process/locks
- Configuration not being loaded from config.json file
- Authentication flow now works end-to-end with JWT tokens
This commit is contained in:
Lukas 2025-10-24 20:55:10 +02:00
parent 4e08d81bb0
commit a3651e0e47
6 changed files with 148 additions and 36 deletions

View File

@ -17,7 +17,8 @@
"keep_days": 30
},
"other": {
"anime_directory": "/home/lukas/Volume/serien/"
"anime_directory": "/home/lukas/Volume/serien/",
"master_password_hash": "$pbkdf2-sha256$29000$ZWwtJaQ0ZkxpLUWolRJijA$QcfgTBqgM3ABu9N93/w8naBLdfCKmKFc65Cn/f4fP84"
},
"version": "1.0.0"
}

View File

@ -30,7 +30,7 @@ def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.
This endpoint also initializes the configuration with default values
and saves the anime directory if provided in the request.
and saves the anime directory and master password hash.
"""
if auth_service.is_configured():
raise HTTPException(
@ -39,10 +39,13 @@ def setup_auth(req: SetupRequest):
)
try:
# Set up master password
auth_service.setup_master_password(req.master_password)
# Set up master password (this validates and hashes it)
password_hash = auth_service.setup_master_password(
req.master_password
)
# Initialize or update config with anime directory if provided
# Initialize or update config with master password hash
# and anime directory
config_service = get_config_service()
try:
config = config_service.load_config()
@ -50,9 +53,14 @@ def setup_auth(req: SetupRequest):
# If config doesn't exist, create default
config = AppConfig()
# Store master password hash in config's other field
config.other['master_password_hash'] = password_hash
# Store anime directory in config's other field if provided
if hasattr(req, 'anime_directory') and req.anime_directory:
config.other['anime_directory'] = req.anime_directory
# Save the config with the password hash and anime directory
config_service.save_config(config, create_backup=False)
except ValueError as e:

View File

@ -53,10 +53,30 @@ async def lifespan(app: FastAPI):
"""Manage application lifespan (startup and shutdown)."""
# Startup
try:
# Load configuration from config.json and sync with settings
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
# Sync anime_directory from config.json to settings
if config.other and config.other.get("anime_directory"):
settings.anime_directory = str(config.other["anime_directory"])
print(
f"Loaded anime_directory from config: "
f"{settings.anime_directory}"
)
except Exception as e:
print(f"Warning: Failed to load config from config.json: {e}")
# Initialize SeriesApp with configured directory and store it on
# application state so it can be injected via dependencies.
if settings.anime_directory:
app.state.series_app = SeriesApp(settings.anime_directory)
print(
f"SeriesApp initialized with directory: "
f"{settings.anime_directory}"
)
else:
# Log warning when anime directory is not configured
print(

View File

@ -45,7 +45,26 @@ class AuthService:
"""
def __init__(self) -> None:
self._hash: Optional[str] = settings.master_password_hash
# Try to load master password hash from config file first
# If not found, fallback to environment variable
self._hash: Optional[str] = None
# Try loading from config file
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
hash_val = config.other.get('master_password_hash')
if isinstance(hash_val, str):
self._hash = hash_val
except Exception:
# Config doesn't exist or can't be loaded - that's OK
pass
# If not in config, try environment variable
if not self._hash:
self._hash = settings.master_password_hash
# In-memory failed attempts per identifier. Values are dicts with
# keys: count, last, locked_until
# WARNING: In-memory storage resets on process restart.
@ -81,7 +100,7 @@ class AuthService:
def is_configured(self) -> bool:
return bool(self._hash)
def setup_master_password(self, password: str) -> None:
def setup_master_password(self, password: str) -> str:
"""Set the master password (hash and store in memory/settings).
Enforces strong password requirements:
@ -91,12 +110,15 @@ class AuthService:
- At least one special character
For now we update only the in-memory value and
settings.master_password_hash. A future task should persist this
to a config file.
settings.master_password_hash. Caller should persist the returned
hash to a config file.
Args:
password: The password to set
Returns:
str: The hashed password
Raises:
ValueError: If password doesn't meet requirements
"""
@ -130,6 +152,8 @@ class AuthService:
# Settings may be frozen or not persisted - that's okay for now
pass
return h
# --- failed attempts and lockout ---
def _get_fail_record(self, identifier: str) -> Dict:
return self._failed.setdefault(

View File

@ -68,6 +68,17 @@ def get_series_app() -> SeriesApp:
"""
global _series_app
# Try to load anime_directory from config.json if not in settings
if not settings.anime_directory:
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
if config.other and config.other.get("anime_directory"):
settings.anime_directory = str(config.other["anime_directory"])
except Exception:
pass # Will raise 503 below if still not configured
if not settings.anime_directory:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@ -104,6 +115,17 @@ def get_optional_series_app() -> Optional[SeriesApp]:
"""
global _series_app
# Try to load anime_directory from config.json if not in settings
if not settings.anime_directory:
try:
from src.server.services.config_service import get_config_service
config_service = get_config_service()
config = config_service.load_config()
if config.other and config.other.get("anime_directory"):
settings.anime_directory = str(config.other["anime_directory"])
except Exception:
return None
if not settings.anime_directory:
return None
@ -283,16 +305,23 @@ async def rate_limit_dependency(request: Request) -> None:
async with _rate_limit_lock:
record = _RATE_LIMIT_BUCKETS.get(client_id)
if not record or now - record.window_start >= _RATE_LIMIT_WINDOW_SECONDS:
window_expired = (
not record
or now - record.window_start >= _RATE_LIMIT_WINDOW_SECONDS
)
if window_expired:
_RATE_LIMIT_BUCKETS[client_id] = RateLimitRecord(
count=1,
window_start=now,
)
return
if record: # Type guard to satisfy mypy
record.count += 1
if record.count > max_requests:
logger.warning("Rate limit exceeded", extra={"client": client_id})
logger.warning(
"Rate limit exceeded", extra={"client": client_id}
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many requests. Please slow down.",

View File

@ -42,24 +42,40 @@ class AniWorldApp {
try {
// First check if we have a token
const token = localStorage.getItem('access_token');
console.log('checkAuthentication: token exists =', !!token);
// Build request with token if available
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
if (!token) {
console.log('checkAuthentication: No token found, redirecting to /login');
window.location.href = '/login';
return;
}
// Build request with token
const headers = {
'Authorization': `Bearer ${token}`
};
const response = await fetch('/api/auth/status', { headers });
console.log('checkAuthentication: response status =', response.status);
if (!response.ok) {
console.log('checkAuthentication: Response not OK, status =', response.status);
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('checkAuthentication: data =', data);
if (!data.configured) {
// No master password set, redirect to setup
console.log('checkAuthentication: Not configured, redirecting to /setup');
window.location.href = '/setup';
return;
}
if (!data.authenticated) {
// Not authenticated, redirect to login
console.log('checkAuthentication: Not authenticated, redirecting to /login');
localStorage.removeItem('access_token');
localStorage.removeItem('token_expires_at');
window.location.href = '/login';
@ -67,6 +83,7 @@ class AniWorldApp {
}
// User is authenticated, show logout button
console.log('checkAuthentication: Authenticated successfully');
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.style.display = 'block';
@ -539,22 +556,35 @@ class AniWorldApp {
try {
this.showLoading();
const response = await fetch('/api/v1/anime');
const response = await this.makeAuthenticatedRequest('/api/anime');
if (response.status === 401) {
window.location.href = '/login';
if (!response) {
// makeAuthenticatedRequest returns null and handles redirect on auth failure
return;
}
const data = await response.json();
if (data.status === 'success') {
// Check if response has the expected format
if (Array.isArray(data)) {
// API returns array of AnimeSummary objects directly
this.seriesData = data.map(anime => ({
id: anime.id,
name: anime.title,
title: anime.title,
missing_episodes: anime.missing_episodes || 0,
episodeDict: {} // Will be populated when needed
}));
} else if (data.status === 'success') {
// Legacy format support
this.seriesData = data.series;
} else {
this.showToast(`Error loading series: ${data.message || 'Unknown error'}`, 'error');
return;
}
this.applyFiltersAndSort();
this.renderSeries();
} else {
this.showToast(`Error loading series: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error loading series:', error);
this.showToast('Failed to load series', 'error');
@ -783,7 +813,7 @@ class AniWorldApp {
try {
this.showLoading();
const response = await this.makeAuthenticatedRequest('/api/v1/anime/search', {
const response = await this.makeAuthenticatedRequest('/api/anime/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -836,7 +866,7 @@ class AniWorldApp {
async addSeries(link, name) {
try {
const response = await this.makeAuthenticatedRequest('/api/v1/anime/add', {
const response = await this.makeAuthenticatedRequest('/api/anime/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -870,7 +900,7 @@ class AniWorldApp {
try {
const folders = Array.from(this.selectedSeries);
const response = await this.makeAuthenticatedRequest('/api/v1/anime/download', {
const response = await this.makeAuthenticatedRequest('/api/anime/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -894,7 +924,7 @@ class AniWorldApp {
async rescanSeries() {
try {
const response = await this.makeAuthenticatedRequest('/api/v1/anime/rescan', {
const response = await this.makeAuthenticatedRequest('/api/anime/rescan', {
method: 'POST'
});
@ -1030,7 +1060,7 @@ class AniWorldApp {
async checkProcessLocks() {
try {
const response = await this.makeAuthenticatedRequest('/api/v1/anime/process/locks');
const response = await this.makeAuthenticatedRequest('/api/anime/process/locks');
if (!response) {
// If no response, set status as idle
this.updateProcessStatus('rescan', false);
@ -1101,7 +1131,7 @@ class AniWorldApp {
try {
// Load current status
const response = await this.makeAuthenticatedRequest('/api/v1/anime/status');
const response = await this.makeAuthenticatedRequest('/api/anime/status');
if (!response) return;
const data = await response.json();
@ -1600,7 +1630,7 @@ class AniWorldApp {
async refreshStatus() {
try {
const response = await this.makeAuthenticatedRequest('/api/v1/anime/status');
const response = await this.makeAuthenticatedRequest('/api/anime/status');
if (!response) return;
const data = await response.json();