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:
iclaoudezinandClaude Sonnet 5.5 committed 2026-09-30 12:03:36 +00:00
1 parent 14ffd64801
commit 11c1b6379b
9 files changed
+241 -16

No files matched your search

+78 -10
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
# Enable CORS for all routes with specific headers support
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
CORS(app, resources={r"/api/*": {"origins": ["https://213.226.125.13:8088"]}}, supports_credentials=True)
class OpenVPNAPI:
def get_config_value(self, section, key, fallback=None):
@@ -67,19 +67,23 @@ class OpenVPNAPI:
return self.db_manager.get_connection()
def ensure_default_admin(self):
"""Create a default admin user if no users exist"""
"""Create the initial admin only from OVPMON_INITIAL_ADMIN_USER/PASSWORD env (no built-in defaults)"""
conn = self.get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT COUNT(*) FROM users")
if cursor.fetchone()[0] == 0:
# Default: admin / password
password_hash = bcrypt.hashpw('password'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ('admin', password_hash))
user = os.getenv('OVPMON_INITIAL_ADMIN_USER')
pw = os.getenv('OVPMON_INITIAL_ADMIN_PASSWORD')
if not user or not pw:
logger.error("No users exist and OVPMON_INITIAL_ADMIN_USER/OVPMON_INITIAL_ADMIN_PASSWORD are not set: admin NOT created")
return
password_hash = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (user, password_hash))
conn.commit()
logger.info("Default admin user created (admin/password)")
logger.info("Initial admin user created from environment")
except Exception as e:
logger.error(f"Error ensuring default admin: {e}")
logger.error(f"Error ensuring initial admin: {e}")
finally:
conn.close()
@@ -617,7 +621,9 @@ def token_required(f):
try:
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
# In a real app, you might want to verify user still exists in DB
# Temporary 2FA-pending tokens are valid only for /api/auth/verify-2fa
if data.get('is_2fa_pending'):
return jsonify({'success': False, 'error': '2FA verification required'}), 401
except jwt.ExpiredSignatureError:
return jsonify({'success': False, 'error': 'Token has expired'}), 401
except Exception:
@@ -720,7 +726,10 @@ def setup_2fa():
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor")
_c = api.get_db_connection()
_row = _c.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
_c.close()
provisioning_uri = totp.provisioning_uri(name=(_row[0] if _row else "user"), issuer_name="OpenVPN-Monitor")
return jsonify({
'success': True,
@@ -739,7 +748,7 @@ def enable_2fa():
if not secret or not otp:
return jsonify({'success': False, 'error': 'Missing data'}), 400
logger.info(f"Attempting 2FA activation. User OTP: {otp}, Secret: {secret}")
logger.info("Attempting 2FA activation")
totp = pyotp.TOTP(secret)
# Adding valid_window=1 to allow ±30 seconds clock drift
@@ -834,6 +843,65 @@ def change_password():
finally:
conn.close()
USERNAME_RE = __import__('re').compile(r'^[A-Za-z][A-Za-z0-9_.-]{2,31}$')
RESERVED_USERNAMES = {'admin', 'administrator', 'root', 'user', 'test', 'guest'}
def _current_user_id():
token = request.headers['Authorization'].split(' ')[1]
return jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])['user_id']
@app.route('/api/auth/change-username', methods=['POST'])
@token_required
def change_username():
data = request.get_json(silent=True) or {}
new_username = (data.get('new_username') or '').strip()
current_password = data.get('current_password')
otp = data.get('otp')
if not new_username or not current_password:
return jsonify({'success': False, 'error': 'Missing data'}), 400
if not USERNAME_RE.match(new_username) or new_username.lower() in RESERVED_USERNAMES:
return jsonify({'success': False, 'error': 'Invalid username (3-32 chars, letters/digits/._-, must start with a letter, reserved names not allowed)'}), 400
ip = request.remote_addr
if not api.check_rate_limit(ip):
return jsonify({'success': False, 'error': 'Too many attempts. Try again in 15 minutes.'}), 429
try:
user_id = _current_user_id()
except Exception:
return jsonify({'success': False, 'error': 'Token is invalid'}), 401
conn = api.get_db_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT password_hash, totp_secret, is_2fa_enabled FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
if not user or not bcrypt.checkpw(current_password.encode('utf-8'), user[0].encode('utf-8')):
api.record_login_attempt(ip, False)
return jsonify({'success': False, 'error': 'Invalid current password'}), 401
if user[2]:
if not otp or not pyotp.TOTP(user[1]).verify(str(otp), valid_window=1):
api.record_login_attempt(ip, False)
return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401
cursor.execute("SELECT 1 FROM users WHERE lower(username) = lower(?) AND id != ?", (new_username, user_id))
if cursor.fetchone():
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
try:
cursor.execute("UPDATE users SET username = ? WHERE id = ?", (new_username, user_id))
conn.commit()
except sqlite3.IntegrityError:
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
logger.info(f"Username changed for user ID: {user_id}")
return jsonify({'success': True, 'username': new_username})
except Exception as e:
logger.error(f"Error changing username: {e}")
return jsonify({'success': False, 'error': 'Internal server error'}), 500
finally:
conn.close()
# --- USER ROUTES ---
@app.route('/api/v1/user/me', methods=['GET'])
+3 -3
View File
@@ -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"])
+8
View File
@@ -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)
+1 -1
View File
@@ -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
+5
View File
@@ -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)
+10
View File
@@ -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)
+8
View File
@@ -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)
+24 -2
View File
@@ -99,7 +99,7 @@
<div class="user-profile ms-2">
<div class="user-avatar-small bg-primary text-white">
{{ username[0]?.toUpperCase() || 'A' }}
{{ username[0]?.toUpperCase() || 'U' }}
</div>
<div class="user-meta d-none d-md-block">
<span class="username">{{ username }}</span>
@@ -123,9 +123,11 @@
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useAppConfig } from './composables/useAppConfig';
import { useApi } from './composables/useApi';
import { useRoute, useRouter } from 'vue-router';
const { loadConfig, isLoaded } = useAppConfig();
const { apiClient } = useApi();
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
const isDark = ref(false);
const refreshKey = ref(0);
@@ -135,7 +137,24 @@ const route = useRoute();
const router = useRouter();
const isAuthenticated = computed(() => route.name !== 'Login');
const username = ref(localStorage.getItem('ovpmon_user') || 'Admin');
const username = ref(localStorage.getItem('ovpmon_user') || '');
// Keep the header name in sync with the server (login, rename, stale session)
const syncUsername = async () => {
if (!localStorage.getItem('ovpmon_token')) {
username.value = '';
return;
}
username.value = localStorage.getItem('ovpmon_user') || '';
try {
const res = await apiClient.get('/user/me');
if (res.data?.username) {
username.value = res.data.username;
localStorage.setItem('ovpmon_user', res.data.username);
}
} catch (e) { /* 401 is handled by the interceptor */ }
};
const onUserChanged = () => { username.value = localStorage.getItem('ovpmon_user') || ''; };
const handleLogout = () => {
localStorage.removeItem('ovpmon_token');
@@ -162,10 +181,13 @@ const refreshPage = () => {
// Close sidebar on route change
watch(() => route.path, () => {
isSidebarOpen.value = false;
if (route.name !== 'Login') syncUsername();
});
onMounted(async () => {
await loadConfig();
window.addEventListener('ovpmon-user-changed', onUserChanged);
if (route.name !== 'Login') syncUsername();
// Init Theme
const savedTheme = localStorage.getItem('theme') || 'light';
+104
View File
@@ -22,6 +22,9 @@
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
Change Password
</button>
<button class="btn btn-action btn-action-secondary py-2 fw-bold btn-account-action mt-2" @click="showUserModal">
Change Username
</button>
</div>
</div>
</div>
@@ -164,6 +167,55 @@
</button>
</template>
</BaseModal>
<!-- Username Change Modal -->
<BaseModal id="usernameChangeModal" title="Change Account Username" ref="userModal">
<template #body>
<form @submit.prevent="handleChangeUsername" id="userForm">
<div class="mb-3">
<label class="form-label small fw-bold text-muted text-uppercase">New Username</label>
<input
type="text"
class="form-control"
v-model="usernameForm.new_username"
placeholder="3-32 chars: letters, digits, . _ -"
pattern="[A-Za-z][A-Za-z0-9_.\-]{2,31}"
autocomplete="off"
required
>
</div>
<div class="mb-3">
<label class="form-label small fw-bold text-muted text-uppercase">Current Password</label>
<input
type="password"
class="form-control"
v-model="usernameForm.current_password"
placeholder="Enter current password"
required
>
</div>
<div class="mb-2" v-if="isEnabled">
<label class="form-label small fw-bold text-muted text-uppercase">2FA Code</label>
<input
type="text"
class="form-control"
v-model="usernameForm.otp"
placeholder="6-digit code"
inputmode="numeric"
autocomplete="one-time-code"
required
>
</div>
</form>
</template>
<template #footer>
<button type="button" class="btn-action btn-action-secondary" @click="userModal.hide()">Cancel</button>
<button type="submit" form="userForm" class="btn-action btn-action-save" :disabled="userLoading">
<span v-if="userLoading" class="spinner-border spinner-border-sm me-2"></span>
Update Username
</button>
</template>
</BaseModal>
</div>
</template>
@@ -179,6 +231,7 @@ const { apiClient } = useApi();
// UI Refs
const pwModal = ref(null);
const userModal = ref(null);
const confirmModal = ref(null);
const disable2FAModal = ref(null);
@@ -234,6 +287,57 @@ const handleChangePassword = async () => {
}
};
// Username Logic
const userLoading = ref(false);
const usernameForm = reactive({ new_username: '', current_password: '', otp: '' });
const showUserModal = () => {
usernameForm.new_username = '';
usernameForm.current_password = '';
usernameForm.otp = '';
userModal.value.show();
};
const handleChangeUsername = async () => {
if (!/^[A-Za-z][A-Za-z0-9_.-]{2,31}$/.test(usernameForm.new_username)) {
Swal.fire({
title: 'Error!',
text: 'Username must be 3-32 chars, start with a letter, and contain only letters, digits, . _ -',
icon: 'error',
confirmButtonColor: '#EC7C31'
});
return;
}
userLoading.value = true;
try {
const res = await apiClient.post('../auth/change-username', {
new_username: usernameForm.new_username,
current_password: usernameForm.current_password,
otp: usernameForm.otp || undefined
});
try { localStorage.setItem('ovpmon_user', res.data.username); } catch (e) { /* ignore */ }
window.dispatchEvent(new Event('ovpmon-user-changed'));
userModal.value.hide();
Swal.fire({
title: 'Success!',
text: 'Username updated. Use the new username the next time you sign in.',
icon: 'success',
confirmButtonColor: '#1652B8'
});
} catch (err) {
Swal.fire({
title: 'Failed',
text: err.response?.data?.error || 'Failed to update username',
icon: 'error',
confirmButtonColor: '#cf222e'
});
} finally {
userLoading.value = false;
}
};
// 2FA Logic
const isEnabled = ref(false);
const step2 = ref(false);