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
+78
-10
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
+24
-2
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user