Add default credential reset safeguards
This commit is contained in:
+104
-1
@@ -3,8 +3,9 @@ import { spawn, execSync, execFileSync } from 'child_process'
|
||||
import { resolve, dirname, join, delimiter } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { randomBytes, scryptSync } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -19,7 +20,11 @@ const PID_DIR = WEB_UI_HOME
|
||||
const PID_FILE = join(PID_DIR, 'server.pid')
|
||||
const LOG_FILE = join(PID_DIR, 'server.log')
|
||||
const TOKEN_FILE = join(PID_DIR, '.token')
|
||||
const LOGIN_LOCK_FILE = join(WEB_UI_HOME, '.login-lock.json')
|
||||
const WEB_UI_DB_FILE = join(WEB_UI_HOME, 'hermes-web-ui.db')
|
||||
const DEFAULT_PORT = 8648
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = '123456'
|
||||
|
||||
// ─── Auto-fix node-pty native module ──────────────────────────
|
||||
function ensureNativeModules() {
|
||||
@@ -466,6 +471,86 @@ function showStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearLoginLocks(options = {}) {
|
||||
const { silent = false, checkRunning = true } = options
|
||||
const serverRunning = checkRunning ? !!getPid() : false
|
||||
let removed = false
|
||||
|
||||
try {
|
||||
unlinkSync(LOGIN_LOCK_FILE)
|
||||
removed = true
|
||||
if (!silent) console.log(` ✓ Removed login lock file: ${LOGIN_LOCK_FILE}`)
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') {
|
||||
if (!silent) console.log(` ✓ No login lock file found: ${LOGIN_LOCK_FILE}`)
|
||||
} else {
|
||||
if (!silent) console.log(` ✗ Failed to remove login lock file: ${err.message}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent && serverRunning) {
|
||||
console.log(' ⚠ hermes-web-ui is running; restart it to clear in-memory login locks.')
|
||||
console.log(' Run: hermes-web-ui restart')
|
||||
}
|
||||
|
||||
return { path: LOGIN_LOCK_FILE, removed, serverRunning }
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, 64).toString('hex')
|
||||
return `scrypt:${salt}:${hash}`
|
||||
}
|
||||
|
||||
function resetDefaultLogin(options = {}) {
|
||||
const { silent = false } = options
|
||||
mkdirSync(WEB_UI_HOME, { recursive: true })
|
||||
const db = new DatabaseSync(WEB_UI_DB_FILE)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
)
|
||||
`)
|
||||
|
||||
const now = Date.now()
|
||||
const passwordHash = hashPassword(DEFAULT_PASSWORD)
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(DEFAULT_USERNAME)
|
||||
if (existing?.id) {
|
||||
db.prepare(
|
||||
`UPDATE users
|
||||
SET password_hash = ?, role = 'super_admin', status = 'active', updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(passwordHash, now, existing.id)
|
||||
if (!silent) {
|
||||
console.log(` ✓ Reset default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
|
||||
console.log(` Database: ${WEB_UI_DB_FILE}`)
|
||||
}
|
||||
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'updated' }
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'super_admin', 'active', ?, ?)`
|
||||
).run(DEFAULT_USERNAME, passwordHash, now, now)
|
||||
if (!silent) {
|
||||
console.log(` ✓ Created default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
|
||||
console.log(` Database: ${WEB_UI_DB_FILE}`)
|
||||
}
|
||||
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'created' }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const command = process.argv[2] || 'start'
|
||||
|
||||
@@ -485,6 +570,8 @@ Commands:
|
||||
stop Stop the server
|
||||
restart [port] Restart the server
|
||||
status Show server status
|
||||
clear-login-locks Delete the login IP lock file
|
||||
reset-default-login Create or reset the default login (${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD})
|
||||
update Update to latest version and restart
|
||||
upgrade Alias for update
|
||||
version Show version number
|
||||
@@ -493,6 +580,7 @@ Options:
|
||||
-v, --version Show version number
|
||||
-h, --help Show this help message
|
||||
--port <port> Specify port (used with start/restart)
|
||||
--restart Restart after clear-login-locks
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -511,6 +599,19 @@ Options:
|
||||
case 'status':
|
||||
showStatus()
|
||||
break
|
||||
case 'clear-login-locks': {
|
||||
const restartAfterClear = process.argv.includes('--restart')
|
||||
const result = clearLoginLocks()
|
||||
if (restartAfterClear && result.serverRunning) {
|
||||
const port = getRunningPort() ?? getPort()
|
||||
stopDaemon()
|
||||
setTimeout(() => startDaemon(port), 500)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'reset-default-login':
|
||||
resetDefaultLogin()
|
||||
break
|
||||
case 'update':
|
||||
case 'upgrade':
|
||||
doUpdate()
|
||||
@@ -599,7 +700,9 @@ if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
|
||||
}
|
||||
|
||||
export {
|
||||
clearLoginLocks,
|
||||
commandExists,
|
||||
getListeningPids,
|
||||
parseUnixNetstatListeningPids,
|
||||
resetDefaultLogin,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useKeyboard } from '@/composables/useKeyboard'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
||||
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
|
||||
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
|
||||
|
||||
const { isDark, isComic } = useTheme()
|
||||
const { t } = useI18n()
|
||||
@@ -73,6 +74,7 @@ useKeyboard()
|
||||
</main>
|
||||
</div>
|
||||
<SessionSearchModal />
|
||||
<DefaultCredentialPrompt />
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface CurrentUser {
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_login_at: number | null
|
||||
requiresCredentialChange?: boolean
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { NButton, NModal } from "naive-ui";
|
||||
import { fetchCurrentUser } from "@/api/auth";
|
||||
import { getApiKey } from "@/api/client";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const show = ref(false);
|
||||
const loading = ref(false);
|
||||
const checkedToken = ref("");
|
||||
const promptedUserId = ref<number | null>(null);
|
||||
|
||||
function dismissalKey(userId: number): string {
|
||||
return `hermes_default_credentials_prompt_dismissed_${userId}`;
|
||||
}
|
||||
|
||||
async function checkDefaultCredentials() {
|
||||
if (route.name === "login") {
|
||||
show.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getApiKey();
|
||||
if (!token || token === checkedToken.value) return;
|
||||
checkedToken.value = token;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const user = await fetchCurrentUser();
|
||||
promptedUserId.value = user.id;
|
||||
const dismissed = sessionStorage.getItem(dismissalKey(user.id)) === "1";
|
||||
show.value = !!user.requiresCredentialChange && !dismissed;
|
||||
} catch {
|
||||
show.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function remindLater() {
|
||||
if (promptedUserId.value != null) {
|
||||
sessionStorage.setItem(dismissalKey(promptedUserId.value), "1");
|
||||
}
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
function goToAccountSettings() {
|
||||
show.value = false;
|
||||
router.push({ name: "hermes.settings", query: { tab: "account" } });
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
void checkDefaultCredentials();
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="show"
|
||||
preset="dialog"
|
||||
:title="t('login.defaultCredentialTitle')"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<p class="credential-warning-text">
|
||||
{{ t("login.defaultCredentialMessage") }}
|
||||
</p>
|
||||
<template #action>
|
||||
<NButton :disabled="loading" @click="remindLater">
|
||||
{{ t("login.defaultCredentialLater") }}
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="goToAccountSettings">
|
||||
{{ t("login.defaultCredentialAction") }}
|
||||
</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.credential-warning-text {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'Token',
|
||||
usernamePlaceholder: 'Benutzername',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
defaultCredentialsHint: 'Standard-Benutzername: admin. Standard-Passwort: 123456.',
|
||||
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
|
||||
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
|
||||
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche, bitte versuchen Sie es spater erneut',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'Passwort-Login ist fur Benutzerkonten erforderlich.',
|
||||
passwordLoginNotConfigured: 'Passwort-Login ist nicht konfiguriert',
|
||||
passwordLoginConfigured: 'Aktuelles Konto: {username}',
|
||||
defaultCredentialTitle: 'Standardkonto und Passwort andern',
|
||||
defaultCredentialMessage: 'Das aktuelle Konto verwendet noch den Standard-Benutzernamen oder das Standard-Passwort. Um unbefugten Zugriff zu vermeiden, andern Sie Benutzername und Passwort des aktuellen Kontos so bald wie moglich.',
|
||||
defaultCredentialAction: 'Jetzt andern',
|
||||
defaultCredentialLater: 'Spater erinnern',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'Token',
|
||||
usernamePlaceholder: 'Username',
|
||||
passwordPlaceholder: 'Password',
|
||||
defaultCredentialsHint: 'Default username: admin. Default password: 123456.',
|
||||
credentialsRequired: 'Please enter username and password',
|
||||
invalidCredentials: 'Invalid username or password',
|
||||
tooManyAttempts: 'Too many failed attempts, please try again later',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'Password login is required for user accounts.',
|
||||
passwordLoginNotConfigured: 'Password login is not configured',
|
||||
passwordLoginConfigured: 'Current account: {username}',
|
||||
defaultCredentialTitle: 'Change the default account credentials',
|
||||
defaultCredentialMessage: 'This account is still using the default username or password. To prevent unauthorized access, update the username and password as soon as possible.',
|
||||
defaultCredentialAction: 'Update now',
|
||||
defaultCredentialLater: 'Remind me later',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'Token',
|
||||
usernamePlaceholder: 'Nombre de usuario',
|
||||
passwordPlaceholder: 'Contrasena',
|
||||
defaultCredentialsHint: 'Nombre de usuario predeterminado: admin. Contrasena predeterminada: 123456.',
|
||||
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
|
||||
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
|
||||
tooManyAttempts: 'Demasiados intentos fallidos, por favor intente mas tarde',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'El login con contrasena es obligatorio para las cuentas de usuario.',
|
||||
passwordLoginNotConfigured: 'Login con contrasena no configurado',
|
||||
passwordLoginConfigured: 'Cuenta actual: {username}',
|
||||
defaultCredentialTitle: 'Cambia la cuenta y contrasena predeterminadas',
|
||||
defaultCredentialMessage: 'La cuenta actual aun usa el nombre de usuario o la contrasena predeterminados. Para evitar accesos no autorizados, cambia cuanto antes el nombre de usuario y la contrasena de la cuenta actual.',
|
||||
defaultCredentialAction: 'Cambiar ahora',
|
||||
defaultCredentialLater: 'Recordar mas tarde',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'Jeton',
|
||||
usernamePlaceholder: 'Nom d\'utilisateur',
|
||||
passwordPlaceholder: 'Mot de passe',
|
||||
defaultCredentialsHint: 'Nom d utilisateur par defaut : admin. Mot de passe par defaut : 123456.',
|
||||
credentialsRequired: 'Veuillez entrer le nom d\'utilisateur et le mot de passe',
|
||||
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
|
||||
tooManyAttempts: 'Trop de tentatives echouees, veuillez reessayer plus tard',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'Le login par mot de passe est requis pour les comptes utilisateur.',
|
||||
passwordLoginNotConfigured: 'Login par mot de passe non configure',
|
||||
passwordLoginConfigured: 'Compte actuel : {username}',
|
||||
defaultCredentialTitle: 'Modifiez le compte et le mot de passe par defaut',
|
||||
defaultCredentialMessage: 'Le compte connecte utilise encore le nom d utilisateur ou le mot de passe par defaut. Pour eviter tout acces non autorise, modifiez rapidement le nom d utilisateur et le mot de passe du compte actuel.',
|
||||
defaultCredentialAction: 'Modifier maintenant',
|
||||
defaultCredentialLater: 'Me le rappeler plus tard',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'トークン',
|
||||
usernamePlaceholder: 'ユーザー名',
|
||||
passwordPlaceholder: 'パスワード',
|
||||
defaultCredentialsHint: '既定のユーザー名:admin、既定のパスワード:123456',
|
||||
credentialsRequired: 'ユーザー名とパスワードを入力してください',
|
||||
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
|
||||
tooManyAttempts: 'ログイン試行回数が多すぎます。しばらくしてからお試しください',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'ユーザーアカウントにはパスワードログインが必要です。',
|
||||
passwordLoginNotConfigured: 'パスワードログイン未設定',
|
||||
passwordLoginConfigured: '現在のアカウント:{username}',
|
||||
defaultCredentialTitle: '既定のアカウント情報を変更してください',
|
||||
defaultCredentialMessage: '現在のログインアカウントは、既定のユーザー名または既定のパスワードをまだ使用しています。不正アクセスを防ぐため、できるだけ早く現在のアカウントでユーザー名とパスワードを変更してください。',
|
||||
defaultCredentialAction: '変更する',
|
||||
defaultCredentialLater: '後で通知',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: '토큰',
|
||||
usernamePlaceholder: '사용자 이름',
|
||||
passwordPlaceholder: '비밀번호',
|
||||
defaultCredentialsHint: '기본 로그인 이름: admin, 기본 비밀번호: 123456',
|
||||
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
|
||||
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
|
||||
tooManyAttempts: '로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: '사용자 계정에는 비밀번호 로그인이 필요합니다.',
|
||||
passwordLoginNotConfigured: '비밀번호 로그인 미설정',
|
||||
passwordLoginConfigured: '현재 계정: {username}',
|
||||
defaultCredentialTitle: '기본 계정과 비밀번호를 변경하세요',
|
||||
defaultCredentialMessage: '현재 로그인 계정이 아직 기본 사용자 이름 또는 기본 비밀번호를 사용하고 있습니다. 무단 접근을 방지하려면 현재 계정에서 사용자 이름과 비밀번호를 가능한 한 빨리 변경하세요.',
|
||||
defaultCredentialAction: '변경하기',
|
||||
defaultCredentialLater: '나중에 알림',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: 'Token',
|
||||
usernamePlaceholder: 'Nome de usuario',
|
||||
passwordPlaceholder: 'Senha',
|
||||
defaultCredentialsHint: 'Nome de usuario padrao: admin. Senha padrao: 123456.',
|
||||
credentialsRequired: 'Por favor, insira nome de usuario e senha',
|
||||
invalidCredentials: 'Nome de usuario ou senha incorretos',
|
||||
tooManyAttempts: 'Muitas tentativas falhadas, por favor tente novamente mais tarde',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: 'Login por senha e obrigatorio para contas de usuario.',
|
||||
passwordLoginNotConfigured: 'Login por senha nao configurado',
|
||||
passwordLoginConfigured: 'Conta atual: {username}',
|
||||
defaultCredentialTitle: 'Altere a conta e senha padrao',
|
||||
defaultCredentialMessage: 'A conta atual ainda usa o nome de usuario ou a senha padrao. Para evitar acesso nao autorizado, altere o nome de usuario e a senha da conta atual o quanto antes.',
|
||||
defaultCredentialAction: 'Alterar agora',
|
||||
defaultCredentialLater: 'Lembrar depois',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: '權杖登入',
|
||||
usernamePlaceholder: '使用者名稱',
|
||||
passwordPlaceholder: '密碼',
|
||||
defaultCredentialsHint: '預設登入名:admin,預設密碼:123456',
|
||||
credentialsRequired: '請輸入使用者名稱和密碼',
|
||||
invalidCredentials: '使用者名稱或密碼錯誤',
|
||||
tooManyAttempts: '登入失敗次數過多,請稍後再試',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: '使用者帳號必須保留密碼登入。',
|
||||
passwordLoginNotConfigured: '密碼登入未設定',
|
||||
passwordLoginConfigured: '目前帳號:{username}',
|
||||
defaultCredentialTitle: '請修改預設帳號和密碼',
|
||||
defaultCredentialMessage: '目前登入帳號仍在使用預設使用者名稱或預設密碼。為避免未授權存取,請盡快進入目前帳號修改使用者名稱和密碼。',
|
||||
defaultCredentialAction: '去修改',
|
||||
defaultCredentialLater: '稍後提醒',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export default {
|
||||
tokenLogin: '令牌登录',
|
||||
usernamePlaceholder: '用户名',
|
||||
passwordPlaceholder: '密码',
|
||||
defaultCredentialsHint: '默认登录名:admin,默认密码:123456',
|
||||
credentialsRequired: '请输入用户名和密码',
|
||||
invalidCredentials: '用户名或密码错误',
|
||||
tooManyAttempts: '登录失败次数过多,请稍后重试',
|
||||
@@ -37,6 +38,10 @@ export default {
|
||||
removeConfirm: '用户账号必须保留密码登录。',
|
||||
passwordLoginNotConfigured: '密码登录未配置',
|
||||
passwordLoginConfigured: '当前账户:{username}',
|
||||
defaultCredentialTitle: '请修改默认账户和密码',
|
||||
defaultCredentialMessage: '当前登录账户仍在使用默认用户名或默认密码。为了避免未授权访问,请尽快进入当前账户修改用户名和密码。',
|
||||
defaultCredentialAction: '去修改',
|
||||
defaultCredentialLater: '稍后提醒',
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -63,6 +63,7 @@ async function handlePasswordLogin() {
|
||||
</div>
|
||||
<h1 class="login-title">{{ t("login.title") }}</h1>
|
||||
<p class="login-desc">{{ t("login.description") }}</p>
|
||||
<p class="login-default-hint">{{ t("login.defaultCredentialsHint") }}</p>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<input
|
||||
@@ -128,10 +129,17 @@ async function handlePasswordLogin() {
|
||||
.login-desc {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
margin: 0 0 32px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-default-hint {
|
||||
margin: 0 0 28px;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
NTabs,
|
||||
NTabPane,
|
||||
@@ -22,6 +23,41 @@ import { isStoredSuperAdmin } from "@/api/client";
|
||||
const settingsStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
const canManageUsers = isStoredSuperAdmin();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activeTab = ref("account");
|
||||
|
||||
const validTabs = computed(() => new Set([
|
||||
"account",
|
||||
...(canManageUsers ? ["users"] : []),
|
||||
"display",
|
||||
"agent",
|
||||
"memory",
|
||||
"compression",
|
||||
"session",
|
||||
"privacy",
|
||||
"models",
|
||||
"voice",
|
||||
]));
|
||||
|
||||
function normalizeTab(value: unknown): string {
|
||||
const tab = typeof value === "string" ? value : "";
|
||||
return validTabs.value.has(tab) ? tab : "account";
|
||||
}
|
||||
|
||||
function handleTabUpdate(tab: string) {
|
||||
activeTab.value = normalizeTab(tab);
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
tab: activeTab.value === "account" ? undefined : activeTab.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
activeTab.value = normalizeTab(tab);
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.fetchSettings();
|
||||
@@ -40,7 +76,7 @@ onMounted(() => {
|
||||
size="large"
|
||||
:description="t('common.loading')"
|
||||
>
|
||||
<NTabs type="line" animated>
|
||||
<NTabs v-model:value="activeTab" type="line" animated @update:value="handleTabUpdate">
|
||||
<NTabPane name="account" :tab="t('settings.tabs.account')">
|
||||
<AccountSettings />
|
||||
</NTabPane>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Context } from 'koa'
|
||||
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
||||
import {
|
||||
DEFAULT_PASSWORD,
|
||||
DEFAULT_USERNAME,
|
||||
bootstrapDefaultSuperAdmin,
|
||||
countActiveSuperAdmins,
|
||||
@@ -55,6 +56,7 @@ export async function currentUser(ctx: Context) {
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
last_login_at: user.last_login_at,
|
||||
requiresCredentialChange: user.username === DEFAULT_USERNAME || verifyPassword(DEFAULT_PASSWORD, user.password_hash),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,10 +633,10 @@ export async function switchProfile(ctx: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridgeCleanupClient().destroyAll()
|
||||
logger.info('[switchProfile] destroyed all bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
|
||||
const result = await bridgeCleanupClient().destroyProfile(name)
|
||||
logger.info('[switchProfile] destroyed bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
|
||||
} catch (err: any) {
|
||||
logger.warn(err, '[switchProfile] failed to destroy bridge sessions')
|
||||
logger.warn(err, '[switchProfile] failed to destroy bridge sessions for profile "%s"', name)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockFetchCurrentUser = vi.hoisted(() => vi.fn())
|
||||
const mockGetApiKey = vi.hoisted(() => vi.fn())
|
||||
const routeState = vi.hoisted(() => ({ fullPath: '/hermes/chat', name: 'hermes.chat' as any }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
fetchCurrentUser: mockFetchCurrentUser,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
getApiKey: mockGetApiKey,
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
NModal: defineComponent({
|
||||
props: { show: Boolean, title: String },
|
||||
setup(props, { slots }) {
|
||||
return () => props.show
|
||||
? h('div', { class: 'modal' }, [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h('div', { class: 'modal-actions' }, slots.action?.()),
|
||||
])
|
||||
: null
|
||||
},
|
||||
}),
|
||||
NButton: defineComponent({
|
||||
emits: ['click'],
|
||||
setup(_props, { emit, slots }) {
|
||||
return () => h('button', { onClick: () => emit('click') }, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
|
||||
|
||||
describe('DefaultCredentialPrompt', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
routeState.fullPath = '/hermes/chat'
|
||||
routeState.name = 'hermes.chat'
|
||||
mockGetApiKey.mockReturnValue('jwt-token')
|
||||
})
|
||||
|
||||
it('prompts after login when the current user still has default credentials', async () => {
|
||||
mockFetchCurrentUser.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
role: 'super_admin',
|
||||
status: 'active',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
last_login_at: 1,
|
||||
requiresCredentialChange: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(DefaultCredentialPrompt)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(mockFetchCurrentUser).toHaveBeenCalledOnce()
|
||||
expect(wrapper.text()).toContain('login.defaultCredentialMessage')
|
||||
await wrapper.findAll('button')[1].trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'hermes.settings', query: { tab: 'account' } })
|
||||
})
|
||||
|
||||
it('does not prompt on the login route', async () => {
|
||||
routeState.fullPath = '/'
|
||||
routeState.name = 'login'
|
||||
|
||||
mount(DefaultCredentialPrompt)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockFetchCurrentUser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,12 @@ describe('LoginView password login', () => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
|
||||
})
|
||||
|
||||
it('shows the default login hint', () => {
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
expect(wrapper.text()).toContain('login.defaultCredentialsHint')
|
||||
})
|
||||
|
||||
it('shows an error when password login fails', async () => {
|
||||
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { scryptSync, timingSafeEqual } from 'crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
type ChildProcessMocks = {
|
||||
execFileSync: ReturnType<typeof vi.fn>
|
||||
@@ -21,10 +26,20 @@ async function loadCli(overrides: Partial<ChildProcessMocks> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, passwordHash: string): boolean {
|
||||
const [scheme, salt, expectedHex] = passwordHash.split(':')
|
||||
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
|
||||
const expected = Buffer.from(expectedHex, 'hex')
|
||||
const actual = scryptSync(password, salt, expected.length)
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
describe('CLI port detection', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
vi.doUnmock('child_process')
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
@@ -89,4 +104,61 @@ describe('CLI port detection', () => {
|
||||
8648,
|
||||
)).toEqual([2468])
|
||||
})
|
||||
|
||||
it('clears the login lock file from the configured Web UI home', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-locks-'))
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
const lockFile = join(home, '.login-lock.json')
|
||||
writeFileSync(lockFile, '{"passwordIpMap":{}}\n')
|
||||
|
||||
try {
|
||||
const { clearLoginLocks } = await loadCli()
|
||||
const result = clearLoginLocks({ silent: true, checkRunning: false })
|
||||
|
||||
expect(result).toEqual({ path: lockFile, removed: true, serverRunning: false })
|
||||
expect(existsSync(lockFile)).toBe(false)
|
||||
|
||||
const second = clearLoginLocks({ silent: true, checkRunning: false })
|
||||
expect(second).toEqual({ path: lockFile, removed: false, serverRunning: false })
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resets an existing admin user to the default password', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-web-ui-cli-default-login-'))
|
||||
process.env.HERMES_WEB_UI_HOME = home
|
||||
const dbPath = join(home, 'hermes-web-ui.db')
|
||||
|
||||
try {
|
||||
const { resetDefaultLogin } = await loadCli()
|
||||
const created = resetDefaultLogin({ silent: true })
|
||||
expect(created.action).toBe('created')
|
||||
|
||||
const db = new DatabaseSync(dbPath)
|
||||
try {
|
||||
const initial = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get('admin') as any
|
||||
expect(verifyPassword('123456', initial.password_hash)).toBe(true)
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run('scrypt:bad:bad', 'admin')
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const updated = resetDefaultLogin({ silent: true })
|
||||
expect(updated.action).toBe('updated')
|
||||
|
||||
const verifyDb = new DatabaseSync(dbPath)
|
||||
try {
|
||||
const rows = verifyDb.prepare('SELECT id, username, password_hash, role, status FROM users WHERE username = ?').all('admin') as any[]
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(verifyPassword('123456', rows[0].password_hash)).toBe(true)
|
||||
expect(rows[0].role).toBe('super_admin')
|
||||
expect(rows[0].status).toBe('active')
|
||||
} finally {
|
||||
verifyDb.close()
|
||||
}
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,20 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const agentBridgeMocks = vi.hoisted(() => ({
|
||||
destroyAll: vi.fn(),
|
||||
destroyProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
const skillInjectorMocks = vi.hoisted(() => ({
|
||||
injectMissingSkills: vi.fn(),
|
||||
resolveTargetDirForProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
const sessionDeleterMocks = vi.hoisted(() => ({
|
||||
switchProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock hermes-cli
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listProfiles: vi.fn(),
|
||||
@@ -20,6 +34,27 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
importProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
||||
AgentBridgeClient: vi.fn(() => ({
|
||||
destroyAll: agentBridgeMocks.destroyAll,
|
||||
destroyProfile: agentBridgeMocks.destroyProfile,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/skill-injector', () => {
|
||||
const HermesSkillInjector = vi.fn(() => ({
|
||||
injectMissingSkills: skillInjectorMocks.injectMissingSkills,
|
||||
})) as any
|
||||
HermesSkillInjector.resolveTargetDirForProfile = skillInjectorMocks.resolveTargetDirForProfile
|
||||
return { HermesSkillInjector }
|
||||
})
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/session-deleter', () => ({
|
||||
SessionDeleter: {
|
||||
getInstance: vi.fn(() => sessionDeleterMocks),
|
||||
},
|
||||
}))
|
||||
|
||||
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
|
||||
|
||||
describe('Profile Routes', () => {
|
||||
@@ -29,6 +64,9 @@ describe('Profile Routes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 0 })
|
||||
skillInjectorMocks.injectMissingSkills.mockResolvedValue({ targets: [] })
|
||||
skillInjectorMocks.resolveTargetDirForProfile.mockImplementation((name: string) => join('/tmp/hermes-skills', name))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -121,6 +159,43 @@ describe('Profile Routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hermes CLI active profile switch', () => {
|
||||
it('only destroys bridge sessions for the target profile', async () => {
|
||||
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-switch-'))
|
||||
tempHomes.push(hermesHome)
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
const profileDir = join(hermesHome, 'profiles', 'work')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
await writeFile(join(profileDir, 'config.yaml'), 'model:\n default: gpt-test\n', 'utf-8')
|
||||
await writeFile(join(hermesHome, 'active_profile'), 'work\n', 'utf-8')
|
||||
vi.mocked(hermesCli.useProfile).mockResolvedValue('Switched to work')
|
||||
vi.mocked(hermesCli.getProfile).mockResolvedValue({
|
||||
name: 'work',
|
||||
path: profileDir,
|
||||
model: 'gpt-test',
|
||||
provider: 'test',
|
||||
skills: 0,
|
||||
hasEnv: false,
|
||||
hasSoulMd: false,
|
||||
} as any)
|
||||
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 2 })
|
||||
const { switchProfile } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = {
|
||||
request: { body: { name: 'work' } },
|
||||
status: 200,
|
||||
body: undefined,
|
||||
}
|
||||
|
||||
await switchProfile(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body).toMatchObject({ success: true, active: 'work' })
|
||||
expect(agentBridgeMocks.destroyProfile).toHaveBeenCalledWith('work')
|
||||
expect(agentBridgeMocks.destroyAll).not.toHaveBeenCalled()
|
||||
expect(sessionDeleterMocks.switchProfile).toHaveBeenCalledWith('work')
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile avatars', () => {
|
||||
it('stores generated avatar metadata under the Web UI home', async () => {
|
||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
|
||||
|
||||
@@ -200,6 +200,30 @@ describe('user auth tables and middleware', () => {
|
||||
expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/)
|
||||
})
|
||||
|
||||
it('marks the default account credentials as requiring a change', async () => {
|
||||
const { users } = await initUsers()
|
||||
const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')!
|
||||
const ctrl = await import('../../packages/server/src/controllers/auth')
|
||||
|
||||
const defaultCtx = {
|
||||
state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } },
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
await ctrl.currentUser(defaultCtx)
|
||||
expect(defaultCtx.body.user.requiresCredentialChange).toBe(true)
|
||||
|
||||
users.updateUsername(admin.id, 'owner')
|
||||
users.updateUserPassword(admin.id, 'stronger-password')
|
||||
const changedCtx = {
|
||||
state: { user: { id: admin.id, username: 'owner', role: 'super_admin' } },
|
||||
status: 200,
|
||||
body: null,
|
||||
} as any
|
||||
await ctrl.currentUser(changedCtx)
|
||||
expect(changedCtx.body.user.requiresCredentialChange).toBe(false)
|
||||
})
|
||||
|
||||
it('lets super admins create regular admins with profile bindings', async () => {
|
||||
const { users } = await initUsers()
|
||||
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
|
||||
Reference in New Issue
Block a user