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

+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);