Harden auth and API: username change, 2FA fixes, input validation
- Add POST /api/auth/change-username (password + OTP when 2FA is on, format/reserved-name checks, uniqueness) and a Change Username modal in Account.vue; use the real username for the 2FA provisioning URI. - Stop creating the built-in admin/password user; the initial admin is seeded only from OVPMON_INITIAL_ADMIN_USER/PASSWORD. - Reject 2FA-pending temporary tokens on all protected routes (Flask token_required, Profiler verify_token); only /api/auth/verify-2fa accepts them. - Stop logging the OTP and TOTP secret in enable_2fa. - Profiler: validate profile username (pattern + realpath checks in schema, router, pki and generator) to prevent path traversal. - Restrict CORS to the panel origin in Profiler and Flask APIs. - UI: header username no longer sticks to the hardcoded Admin fallback; it is synced from /user/me and updated after a rename. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14ffd64801
commit
11c1b6379b
9 files changed
+241
-16
No files matched your search
@@ -26,10 +26,10 @@ app = FastAPI(
|
||||
# Enable CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=["https://213.226.125.13:8088"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
app.include_router(system.router, prefix="/api", tags=["System"])
|
||||
|
||||
@@ -79,6 +79,11 @@ def create_profile(
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User already exists")
|
||||
|
||||
try:
|
||||
pki.validate_username(profile_in.username)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid username")
|
||||
|
||||
# Build PKI
|
||||
try:
|
||||
pki.build_client(profile_in.username, db)
|
||||
@@ -89,6 +94,9 @@ def create_profile(
|
||||
client_conf_dir = "client-config"
|
||||
os.makedirs(client_conf_dir, exist_ok=True)
|
||||
file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn")
|
||||
base_real = os.path.realpath(client_conf_dir)
|
||||
if not os.path.realpath(file_path).startswith(base_real + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid username")
|
||||
|
||||
try:
|
||||
generator.generate_client_config(db, profile_in.username, file_path)
|
||||
|
||||
@@ -66,7 +66,7 @@ class ConfigResponse(BaseModel):
|
||||
|
||||
# --- User Profile Schemas ---
|
||||
class UserProfileBase(BaseModel):
|
||||
username: str
|
||||
username: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
class UserProfileCreate(UserProfileBase):
|
||||
pass
|
||||
|
||||
@@ -61,6 +61,11 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
return config_content
|
||||
|
||||
def generate_client_config(db: Session, username: str, output_path: str):
|
||||
from .pki import validate_username
|
||||
validate_username(username)
|
||||
base = os.path.realpath(os.path.dirname(output_path) or ".")
|
||||
if os.path.realpath(output_path) != os.path.join(base, os.path.basename(output_path)) or os.path.basename(output_path) != f"{username}.ovpn":
|
||||
raise ValueError("Invalid output path")
|
||||
settings = get_system_settings(db)
|
||||
pki = get_pki_settings(db)
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import re
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||
|
||||
def validate_username(username: str) -> str:
|
||||
if not isinstance(username, str) or not USERNAME_RE.match(username) or ".." in username:
|
||||
raise ValueError("Invalid username")
|
||||
return username
|
||||
|
||||
EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa")
|
||||
PKI_DIR = os.path.join(EASY_RSA_DIR, "pki")
|
||||
INDEX_PATH = os.path.join(PKI_DIR, "index.txt")
|
||||
@@ -170,11 +178,13 @@ def clear_pki(db: Session):
|
||||
return "PKI directory did not exist, but User DB and Client profiles were wiped."
|
||||
|
||||
def build_client(username: str, db: Session):
|
||||
validate_username(username)
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||
return True
|
||||
|
||||
def revoke_client(username: str, db: Session):
|
||||
validate_username(username)
|
||||
env = _get_easyrsa_env(db)
|
||||
_run_easyrsa(["revoke", username], env)
|
||||
_run_easyrsa(["gen-crl"], env)
|
||||
|
||||
@@ -43,7 +43,15 @@ async def verify_token(authorization: str = Header(None)):
|
||||
# print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...")
|
||||
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("is_2fa_pending"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="2FA verification required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return payload
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
error_detail = str(e)
|
||||
|
||||
Reference in new issue
Block a user