mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
fix(auth): centralize password and username validation constants (#4120)
Added PASSWORD_MIN_LENGTH and RESERVED_USERNAMES to src/constants.py as the single source of truth. Previously PASSWORD_MIN_LENGTH was hardcoded as 8 in four route handlers and all three JS validation paths; RESERVED_USERNAMES was an inline frozenset duplicated in core/auth.py, routes/assistant_routes.py, routes/research_routes.py, and src/task_scheduler.py. Added GET /api/auth/policy (unauthenticated) so the frontend reads the real values from the server instead of hardcoding them in JS. Added missing empty-username guard to /setup and admin POST /users. Both returned a misleading 500/409 on whitespace-only input. /signup already had the check; this makes all three consistent.
This commit is contained in:
+10
-1
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|||||||
# backwards for this sentinel.
|
# backwards for this sentinel.
|
||||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||||
|
|
||||||
from src.constants import AUTH_FILE
|
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
@@ -243,6 +243,15 @@ class AuthManager:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return len(self.users) > 0
|
return len(self.users) > 0
|
||||||
|
|
||||||
|
def policy(self) -> dict:
|
||||||
|
"""Return public auth policy constants for the frontend."""
|
||||||
|
return {
|
||||||
|
"password_min_length": PASSWORD_MIN_LENGTH,
|
||||||
|
"reserved_usernames": sorted(RESERVED_USERNAMES),
|
||||||
|
"signup_enabled": self.signup_enabled,
|
||||||
|
"session_days": TOKEN_TTL // 86400,
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Account management
|
# Account management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
|
|
||||||
@@ -89,11 +90,11 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
|||||||
# check-in tasks seeded. Hitting any /assistant route under one of these
|
# check-in tasks seeded. Hitting any /assistant route under one of these
|
||||||
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
||||||
# owner, which then double-fired alongside the real user's check-ins.
|
# owner, which then double-fired alongside the real user's check-ins.
|
||||||
_SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""})
|
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
|
||||||
|
|
||||||
async def _get_or_create(owner: str) -> CrewMember:
|
async def _get_or_create(owner: str) -> CrewMember:
|
||||||
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
||||||
if not owner or owner in _SYNTHETIC_OWNERS:
|
if not owner or owner in RESERVED_USERNAMES:
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
+25
-10
@@ -12,8 +12,8 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||||
from core.auth import AuthManager, SetAdminResult
|
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
|
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
from src.settings import (
|
from src.settings import (
|
||||||
@@ -102,8 +102,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(429, "Too many requests — try again later")
|
raise HTTPException(429, "Too many requests — try again later")
|
||||||
if auth_manager.is_configured:
|
if auth_manager.is_configured:
|
||||||
raise HTTPException(400, "Already configured")
|
raise HTTPException(400, "Already configured")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
if len(body.username.strip()) < 1:
|
||||||
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(500, "Setup failed")
|
raise HTTPException(500, "Setup failed")
|
||||||
@@ -118,10 +122,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(400, "Run setup first")
|
raise HTTPException(400, "Run setup first")
|
||||||
if not auth_manager.signup_enabled:
|
if not auth_manager.signup_enabled:
|
||||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
if len(body.username.strip()) < 1:
|
if len(body.username.strip()) < 1:
|
||||||
raise HTTPException(400, "Username is required")
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
@@ -184,13 +190,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@router.get("/policy")
|
||||||
|
async def auth_policy():
|
||||||
|
"""Return public auth policy constants for the frontend."""
|
||||||
|
return auth_manager.policy()
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(body: ChangePasswordRequest, request: Request):
|
async def change_password(body: ChangePasswordRequest, request: Request):
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
current_token = request.cookies.get(SESSION_COOKIE)
|
current_token = request.cookies.get(SESSION_COOKIE)
|
||||||
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -270,8 +281,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
if len(body.password) < 8:
|
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||||
raise HTTPException(400, "Password must be at least 8 characters")
|
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
if len(body.username.strip()) < 1:
|
||||||
|
raise HTTPException(400, "Username is required")
|
||||||
|
if body.username.lower() in RESERVED_USERNAMES:
|
||||||
|
raise HTTPException(403, "Username is reserved")
|
||||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.constants import DEEP_RESEARCH_DIR
|
from src.constants import DEEP_RESEARCH_DIR
|
||||||
|
|
||||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||||
@@ -387,7 +388,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
user = require_privilege(request, "can_use_research")
|
user = require_privilege(request, "can_use_research")
|
||||||
if user == "internal-tool":
|
if user == "internal-tool":
|
||||||
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
||||||
if tool_owner and tool_owner not in {"internal-tool", "api", "demo", "system"}:
|
if tool_owner and tool_owner not in RESERVED_USERNAMES:
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ sys.path.insert(0, BASE_DIR)
|
|||||||
from src.constants import (
|
from src.constants import (
|
||||||
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
|
DATA_DIR, AUTH_FILE, UPLOAD_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR,
|
||||||
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
|
TTS_CACHE_DIR, GENERATED_IMAGES_DIR, DEEP_RESEARCH_DIR, CHROMA_DIR,
|
||||||
RAG_DIR, MEMORY_VECTORS_DIR,
|
RAG_DIR, MEMORY_VECTORS_DIR, PASSWORD_MIN_LENGTH,
|
||||||
)
|
)
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
|
||||||
DIRS = [
|
DIRS = [
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
@@ -59,15 +60,23 @@ def _prompt_admin_credentials():
|
|||||||
print(" (Press Enter to accept defaults)")
|
print(" (Press Enter to accept defaults)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
username = input(" Username [admin]: ").strip().lower()
|
while True:
|
||||||
if not username:
|
username = input(" Username [admin]: ").strip().lower()
|
||||||
username = "admin"
|
if not username:
|
||||||
|
username = "admin"
|
||||||
|
if username in RESERVED_USERNAMES:
|
||||||
|
print(f" '{username}' is a reserved username. Choose another.")
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
password = getpass.getpass(" Password: ")
|
password = getpass.getpass(" Password: ")
|
||||||
if not password:
|
if not password:
|
||||||
print(" Password cannot be empty.")
|
print(" Password cannot be empty.")
|
||||||
continue
|
continue
|
||||||
|
if len(password) < PASSWORD_MIN_LENGTH:
|
||||||
|
print(f" Password must be at least {PASSWORD_MIN_LENGTH} characters.")
|
||||||
|
continue
|
||||||
confirm = getpass.getpass(" Confirm password: ")
|
confirm = getpass.getpass(" Confirm password: ")
|
||||||
if password != confirm:
|
if password != confirm:
|
||||||
print(" Passwords don't match. Try again.")
|
print(" Passwords don't match. Try again.")
|
||||||
@@ -93,8 +102,13 @@ def create_default_admin():
|
|||||||
password = os.getenv("ODYSSEUS_ADMIN_PASSWORD", "").strip()
|
password = os.getenv("ODYSSEUS_ADMIN_PASSWORD", "").strip()
|
||||||
|
|
||||||
if username and password:
|
if username and password:
|
||||||
# Both provided via env — use them directly
|
# Both provided via env — validate before using
|
||||||
pass
|
if username in RESERVED_USERNAMES:
|
||||||
|
print(f" [error] ODYSSEUS_ADMIN_USER '{username}' is a reserved username")
|
||||||
|
return "failed"
|
||||||
|
if len(password) < PASSWORD_MIN_LENGTH:
|
||||||
|
print(f" [error] ODYSSEUS_ADMIN_PASSWORD must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||||
|
return "failed"
|
||||||
elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"):
|
elif sys.stdin.isatty() and not os.getenv("ODYSSEUS_SKIP_ADMIN_PROMPT"):
|
||||||
# Interactive terminal — ask the user
|
# Interactive terminal — ask the user
|
||||||
username, password = _prompt_admin_credentials()
|
username, password = _prompt_admin_credentials()
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ SEARXNG_INSTANCE = os.getenv("SEARXNG_INSTANCE", "http://localhost:8080")
|
|||||||
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
||||||
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
||||||
|
|
||||||
|
# Auth policy
|
||||||
|
PASSWORD_MIN_LENGTH = 8
|
||||||
|
|
||||||
# Default parameters
|
# Default parameters
|
||||||
DEFAULT_TEMPERATURE = 1.0
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
DEFAULT_MAX_TOKENS = 0
|
DEFAULT_MAX_TOKENS = 0
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import uuid
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
from core.auth import RESERVED_USERNAMES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -2221,7 +2223,7 @@ class TaskScheduler:
|
|||||||
# check-ins seeded, which then double-fire alongside the human user's
|
# check-ins seeded, which then double-fire alongside the human user's
|
||||||
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
# check-ins. This was the root cause of the duplicate 'Morning check-in'
|
||||||
# rows we had to manually clean up.
|
# rows we had to manually clean up.
|
||||||
if not owner or owner in {"internal-tool", "api", "demo", "system"}:
|
if not owner or owner in RESERVED_USERNAMES:
|
||||||
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}")
|
||||||
return
|
return
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
|
|||||||
+2
-2
@@ -1913,7 +1913,7 @@
|
|||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||||
<div class="settings-col">
|
<div class="settings-col">
|
||||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<input id="settings-pw-new" type="password" placeholder="New password (min 8)" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-new" type="password" placeholder="New password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||||
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
||||||
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||||
@@ -2049,7 +2049,7 @@
|
|||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||||
<div class="admin-add-form">
|
<div class="admin-add-form">
|
||||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
<input id="adm-newPassword" type="password" placeholder="Password">
|
||||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row" style="margin-top:6px;">
|
<div class="settings-row" style="margin-top:6px;">
|
||||||
|
|||||||
+12
-1
@@ -13,6 +13,7 @@ let modalEl = null;
|
|||||||
// the endpoints list can flash a glow on that row. Cleared once the
|
// the endpoints list can flash a glow on that row. Cleared once the
|
||||||
// animation fires.
|
// animation fires.
|
||||||
let _recentlyAddedEpId = null;
|
let _recentlyAddedEpId = null;
|
||||||
|
let _authPolicy = { password_min_length: 8, reserved_usernames: [] };
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
function esc(s) { return uiModule.esc(s); }
|
function esc(s) { return uiModule.esc(s); }
|
||||||
@@ -343,6 +344,15 @@ function initSignupToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initAddUser() {
|
function initAddUser() {
|
||||||
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(policy => {
|
||||||
|
if (!policy) return;
|
||||||
|
_authPolicy = policy;
|
||||||
|
const admPw = el('adm-newPassword');
|
||||||
|
if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
el('adm-addBtn').addEventListener('click', async () => {
|
el('adm-addBtn').addEventListener('click', async () => {
|
||||||
const msg = el('adm-addMsg');
|
const msg = el('adm-addMsg');
|
||||||
msg.textContent = ''; msg.className = '';
|
msg.textContent = ''; msg.className = '';
|
||||||
@@ -350,7 +360,8 @@ function initAddUser() {
|
|||||||
const password = el('adm-newPassword').value;
|
const password = el('adm-newPassword').value;
|
||||||
const is_admin = el('adm-newIsAdmin').checked;
|
const is_admin = el('adm-newIsAdmin').checked;
|
||||||
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
||||||
if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; }
|
if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; }
|
||||||
|
if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; }
|
||||||
el('adm-addBtn').disabled = true;
|
el('adm-addBtn').disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
||||||
|
|||||||
+12
-1
@@ -11,6 +11,7 @@ import { isAltGrEvent } from './platform.js';
|
|||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
|
let _authPolicy = { password_min_length: 8 };
|
||||||
|
|
||||||
function el(id) { return document.getElementById(id); }
|
function el(id) { return document.getElementById(id); }
|
||||||
function esc(s) { return uiModule.esc(s); }
|
function esc(s) { return uiModule.esc(s); }
|
||||||
@@ -2160,6 +2161,16 @@ function initAccount() {
|
|||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Update password placeholder and policy from server
|
||||||
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(policy => {
|
||||||
|
if (!policy) return;
|
||||||
|
_authPolicy = policy;
|
||||||
|
const pwNew = el('settings-pw-new');
|
||||||
|
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
const saveBtn = el('settings-pw-save');
|
const saveBtn = el('settings-pw-save');
|
||||||
const msgEl = el('settings-pw-msg');
|
const msgEl = el('settings-pw-msg');
|
||||||
@@ -2170,7 +2181,7 @@ function initAccount() {
|
|||||||
const conf = el('settings-pw-confirm').value;
|
const conf = el('settings-pw-confirm').value;
|
||||||
msgEl.style.color = '';
|
msgEl.style.color = '';
|
||||||
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
||||||
if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; }
|
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
|
||||||
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+18
-5
@@ -328,6 +328,7 @@
|
|||||||
|
|
||||||
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
||||||
let signupAllowed = false;
|
let signupAllowed = false;
|
||||||
|
let policy = { password_min_length: 8, reserved_usernames: [] };
|
||||||
|
|
||||||
const rememberToggle = document.getElementById('rememberToggle');
|
const rememberToggle = document.getElementById('rememberToggle');
|
||||||
|
|
||||||
@@ -360,10 +361,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auth status
|
// Check auth status and fetch policy in parallel, but don't block the
|
||||||
|
// authenticated redirect on the policy response.
|
||||||
|
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||||
const data = await res.json();
|
const data = await statusRes.json();
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
return;
|
return;
|
||||||
@@ -374,6 +377,10 @@
|
|||||||
} else {
|
} else {
|
||||||
setMode('login');
|
setMode('login');
|
||||||
}
|
}
|
||||||
|
const policyRes = await policyPromise;
|
||||||
|
if (policyRes && policyRes.ok) {
|
||||||
|
policy = await policyRes.json();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMode('login');
|
setMode('login');
|
||||||
}
|
}
|
||||||
@@ -426,8 +433,14 @@
|
|||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (password.length < policy.password_min_length) {
|
||||||
errEl.textContent = 'Password must be at least 8 characters';
|
errEl.textContent = `Password must be at least ${policy.password_min_length} characters`;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (policy.reserved_usernames.includes(username.toLowerCase())) {
|
||||||
|
errEl.textContent = 'This username is reserved';
|
||||||
errEl.style.display = 'block';
|
errEl.style.display = 'block';
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Tests for auth policy endpoint and password length validation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from tests.helpers.import_state import clear_module
|
||||||
|
|
||||||
|
|
||||||
|
def _real_core_package():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
core_path = str(root / "core")
|
||||||
|
core = sys.modules.get("core")
|
||||||
|
if core is None:
|
||||||
|
core = types.ModuleType("core")
|
||||||
|
sys.modules["core"] = core
|
||||||
|
core.__path__ = [core_path]
|
||||||
|
clear_module("core.auth")
|
||||||
|
return core
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_module():
|
||||||
|
_real_core_package()
|
||||||
|
return importlib.import_module("core.auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(tmp_path):
|
||||||
|
auth_mod = _auth_module()
|
||||||
|
auth_mod._hash_password = lambda password: f"hash:{password}"
|
||||||
|
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
|
||||||
|
auth_path = tmp_path / "auth.json"
|
||||||
|
mgr = auth_mod.AuthManager(str(auth_path))
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
async def _immediate_to_thread(fn, *args, **kwargs):
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ── AuthManager.policy() ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_password_min_length(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["password_min_length"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_reserved_usernames(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert "internal-tool" in policy["reserved_usernames"]
|
||||||
|
assert "api" in policy["reserved_usernames"]
|
||||||
|
assert "demo" in policy["reserved_usernames"]
|
||||||
|
assert "system" in policy["reserved_usernames"]
|
||||||
|
assert isinstance(policy["reserved_usernames"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_signup_enabled(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["signup_enabled"] is False # default
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_returns_session_days(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
policy = mgr.policy()
|
||||||
|
assert policy["session_days"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/auth/policy endpoint ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/policy":
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("policy route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_endpoint_returns_dict(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint = _policy_endpoint(mgr)
|
||||||
|
result = asyncio.run(endpoint())
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert "password_min_length" in result
|
||||||
|
assert "reserved_usernames" in result
|
||||||
|
assert "signup_enabled" in result
|
||||||
|
assert "session_days" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_endpoint_values_match_manager(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint = _policy_endpoint(mgr)
|
||||||
|
result = asyncio.run(endpoint())
|
||||||
|
assert result == mgr.policy()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password length validation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import SetupRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/setup":
|
||||||
|
return route.endpoint, SetupRequest
|
||||||
|
raise AssertionError("setup route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _signup_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import SignupRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/signup":
|
||||||
|
return route.endpoint, SignupRequest
|
||||||
|
raise AssertionError("signup route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _change_password_endpoint(auth_manager):
|
||||||
|
sys.modules.pop("routes.auth_routes", None)
|
||||||
|
_real_core_package()
|
||||||
|
from routes.auth_routes import ChangePasswordRequest, setup_auth_routes
|
||||||
|
|
||||||
|
router = setup_auth_routes(auth_manager)
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", None) == "/api/auth/change-password":
|
||||||
|
return route.endpoint, ChangePasswordRequest
|
||||||
|
raise AssertionError("change-password route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create_user("admin", "admin-password", is_admin=True)
|
||||||
|
mgr.signup_enabled = True
|
||||||
|
endpoint, SignupRequest = _signup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SignupRequest(username="newuser", password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_rejects_short_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
mgr.create_user("alice", "old-password", is_admin=False)
|
||||||
|
endpoint, ChangePasswordRequest = _change_password_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
cookies={"odysseus_session": "current-token"},
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
)
|
||||||
|
# Mock get_username_for_token to return alice
|
||||||
|
mgr.get_username_for_token = MagicMock(return_value="alice")
|
||||||
|
body = ChangePasswordRequest(current_password="old-password", new_password="short")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "8 characters" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_accepts_exactly_min_length_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="12345678")
|
||||||
|
|
||||||
|
result = asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert result == {"ok": True, "message": "Admin account created"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_rejects_seven_char_password(tmp_path):
|
||||||
|
mgr = _make_manager(tmp_path)
|
||||||
|
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||||
|
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||||
|
body = SetupRequest(username="admin", password="1234567")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(endpoint(body=body, request=request))
|
||||||
|
|
||||||
|
assert exc.value.status_code == 400
|
||||||
Reference in New Issue
Block a user