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 { resolve, dirname, join, delimiter } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
|
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 { homedir } from 'os'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const __filename = 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 PID_FILE = join(PID_DIR, 'server.pid')
|
||||||
const LOG_FILE = join(PID_DIR, 'server.log')
|
const LOG_FILE = join(PID_DIR, 'server.log')
|
||||||
const TOKEN_FILE = join(PID_DIR, '.token')
|
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_PORT = 8648
|
||||||
|
const DEFAULT_USERNAME = 'admin'
|
||||||
|
const DEFAULT_PASSWORD = '123456'
|
||||||
|
|
||||||
// ─── Auto-fix node-pty native module ──────────────────────────
|
// ─── Auto-fix node-pty native module ──────────────────────────
|
||||||
function ensureNativeModules() {
|
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() {
|
function main() {
|
||||||
const command = process.argv[2] || 'start'
|
const command = process.argv[2] || 'start'
|
||||||
|
|
||||||
@@ -485,6 +570,8 @@ Commands:
|
|||||||
stop Stop the server
|
stop Stop the server
|
||||||
restart [port] Restart the server
|
restart [port] Restart the server
|
||||||
status Show server status
|
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
|
update Update to latest version and restart
|
||||||
upgrade Alias for update
|
upgrade Alias for update
|
||||||
version Show version number
|
version Show version number
|
||||||
@@ -493,6 +580,7 @@ Options:
|
|||||||
-v, --version Show version number
|
-v, --version Show version number
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
--port <port> Specify port (used with start/restart)
|
--port <port> Specify port (used with start/restart)
|
||||||
|
--restart Restart after clear-login-locks
|
||||||
`)
|
`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
@@ -511,6 +599,19 @@ Options:
|
|||||||
case 'status':
|
case 'status':
|
||||||
showStatus()
|
showStatus()
|
||||||
break
|
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 'update':
|
||||||
case 'upgrade':
|
case 'upgrade':
|
||||||
doUpdate()
|
doUpdate()
|
||||||
@@ -599,7 +700,9 @@ if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
clearLoginLocks,
|
||||||
commandExists,
|
commandExists,
|
||||||
getListeningPids,
|
getListeningPids,
|
||||||
parseUnixNetstatListeningPids,
|
parseUnixNetstatListeningPids,
|
||||||
|
resetDefaultLogin,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useKeyboard } from '@/composables/useKeyboard'
|
|||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
||||||
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
|
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
|
||||||
|
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
|
||||||
|
|
||||||
const { isDark, isComic } = useTheme()
|
const { isDark, isComic } = useTheme()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -73,6 +74,7 @@ useKeyboard()
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<SessionSearchModal />
|
<SessionSearchModal />
|
||||||
|
<DefaultCredentialPrompt />
|
||||||
</NNotificationProvider>
|
</NNotificationProvider>
|
||||||
</NDialogProvider>
|
</NDialogProvider>
|
||||||
</NMessageProvider>
|
</NMessageProvider>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface CurrentUser {
|
|||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
last_login_at: number | null
|
last_login_at: number | null
|
||||||
|
requiresCredentialChange?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
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',
|
tokenLogin: 'Token',
|
||||||
usernamePlaceholder: 'Benutzername',
|
usernamePlaceholder: 'Benutzername',
|
||||||
passwordPlaceholder: 'Passwort',
|
passwordPlaceholder: 'Passwort',
|
||||||
|
defaultCredentialsHint: 'Standard-Benutzername: admin. Standard-Passwort: 123456.',
|
||||||
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
|
credentialsRequired: 'Bitte Benutzername und Passwort eingeben',
|
||||||
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
|
invalidCredentials: 'Ungultiger Benutzername oder Passwort',
|
||||||
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche, bitte versuchen Sie es spater erneut',
|
tooManyAttempts: 'Zu viele fehlgeschlagene Versuche, bitte versuchen Sie es spater erneut',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: 'Passwort-Login ist fur Benutzerkonten erforderlich.',
|
removeConfirm: 'Passwort-Login ist fur Benutzerkonten erforderlich.',
|
||||||
passwordLoginNotConfigured: 'Passwort-Login ist nicht konfiguriert',
|
passwordLoginNotConfigured: 'Passwort-Login ist nicht konfiguriert',
|
||||||
passwordLoginConfigured: 'Aktuelles Konto: {username}',
|
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: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: 'Token',
|
tokenLogin: 'Token',
|
||||||
usernamePlaceholder: 'Username',
|
usernamePlaceholder: 'Username',
|
||||||
passwordPlaceholder: 'Password',
|
passwordPlaceholder: 'Password',
|
||||||
|
defaultCredentialsHint: 'Default username: admin. Default password: 123456.',
|
||||||
credentialsRequired: 'Please enter username and password',
|
credentialsRequired: 'Please enter username and password',
|
||||||
invalidCredentials: 'Invalid username or password',
|
invalidCredentials: 'Invalid username or password',
|
||||||
tooManyAttempts: 'Too many failed attempts, please try again later',
|
tooManyAttempts: 'Too many failed attempts, please try again later',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: 'Password login is required for user accounts.',
|
removeConfirm: 'Password login is required for user accounts.',
|
||||||
passwordLoginNotConfigured: 'Password login is not configured',
|
passwordLoginNotConfigured: 'Password login is not configured',
|
||||||
passwordLoginConfigured: 'Current account: {username}',
|
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: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: 'Token',
|
tokenLogin: 'Token',
|
||||||
usernamePlaceholder: 'Nombre de usuario',
|
usernamePlaceholder: 'Nombre de usuario',
|
||||||
passwordPlaceholder: 'Contrasena',
|
passwordPlaceholder: 'Contrasena',
|
||||||
|
defaultCredentialsHint: 'Nombre de usuario predeterminado: admin. Contrasena predeterminada: 123456.',
|
||||||
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
|
credentialsRequired: 'Por favor, introduzca nombre de usuario y contrasena',
|
||||||
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
|
invalidCredentials: 'Nombre de usuario o contrasena incorrectos',
|
||||||
tooManyAttempts: 'Demasiados intentos fallidos, por favor intente mas tarde',
|
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.',
|
removeConfirm: 'El login con contrasena es obligatorio para las cuentas de usuario.',
|
||||||
passwordLoginNotConfigured: 'Login con contrasena no configurado',
|
passwordLoginNotConfigured: 'Login con contrasena no configurado',
|
||||||
passwordLoginConfigured: 'Cuenta actual: {username}',
|
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: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: 'Jeton',
|
tokenLogin: 'Jeton',
|
||||||
usernamePlaceholder: 'Nom d\'utilisateur',
|
usernamePlaceholder: 'Nom d\'utilisateur',
|
||||||
passwordPlaceholder: 'Mot de passe',
|
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',
|
credentialsRequired: 'Veuillez entrer le nom d\'utilisateur et le mot de passe',
|
||||||
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
|
invalidCredentials: 'Nom d\'utilisateur ou mot de passe incorrect',
|
||||||
tooManyAttempts: 'Trop de tentatives echouees, veuillez reessayer plus tard',
|
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.',
|
removeConfirm: 'Le login par mot de passe est requis pour les comptes utilisateur.',
|
||||||
passwordLoginNotConfigured: 'Login par mot de passe non configure',
|
passwordLoginNotConfigured: 'Login par mot de passe non configure',
|
||||||
passwordLoginConfigured: 'Compte actuel : {username}',
|
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: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: 'トークン',
|
tokenLogin: 'トークン',
|
||||||
usernamePlaceholder: 'ユーザー名',
|
usernamePlaceholder: 'ユーザー名',
|
||||||
passwordPlaceholder: 'パスワード',
|
passwordPlaceholder: 'パスワード',
|
||||||
|
defaultCredentialsHint: '既定のユーザー名:admin、既定のパスワード:123456',
|
||||||
credentialsRequired: 'ユーザー名とパスワードを入力してください',
|
credentialsRequired: 'ユーザー名とパスワードを入力してください',
|
||||||
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
|
invalidCredentials: 'ユーザー名またはパスワードが正しくありません',
|
||||||
tooManyAttempts: 'ログイン試行回数が多すぎます。しばらくしてからお試しください',
|
tooManyAttempts: 'ログイン試行回数が多すぎます。しばらくしてからお試しください',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: 'ユーザーアカウントにはパスワードログインが必要です。',
|
removeConfirm: 'ユーザーアカウントにはパスワードログインが必要です。',
|
||||||
passwordLoginNotConfigured: 'パスワードログイン未設定',
|
passwordLoginNotConfigured: 'パスワードログイン未設定',
|
||||||
passwordLoginConfigured: '現在のアカウント:{username}',
|
passwordLoginConfigured: '現在のアカウント:{username}',
|
||||||
|
defaultCredentialTitle: '既定のアカウント情報を変更してください',
|
||||||
|
defaultCredentialMessage: '現在のログインアカウントは、既定のユーザー名または既定のパスワードをまだ使用しています。不正アクセスを防ぐため、できるだけ早く現在のアカウントでユーザー名とパスワードを変更してください。',
|
||||||
|
defaultCredentialAction: '変更する',
|
||||||
|
defaultCredentialLater: '後で通知',
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: '토큰',
|
tokenLogin: '토큰',
|
||||||
usernamePlaceholder: '사용자 이름',
|
usernamePlaceholder: '사용자 이름',
|
||||||
passwordPlaceholder: '비밀번호',
|
passwordPlaceholder: '비밀번호',
|
||||||
|
defaultCredentialsHint: '기본 로그인 이름: admin, 기본 비밀번호: 123456',
|
||||||
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
|
credentialsRequired: '사용자 이름과 비밀번호를 입력해 주세요',
|
||||||
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
|
invalidCredentials: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
|
||||||
tooManyAttempts: '로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요',
|
tooManyAttempts: '로그인 시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: '사용자 계정에는 비밀번호 로그인이 필요합니다.',
|
removeConfirm: '사용자 계정에는 비밀번호 로그인이 필요합니다.',
|
||||||
passwordLoginNotConfigured: '비밀번호 로그인 미설정',
|
passwordLoginNotConfigured: '비밀번호 로그인 미설정',
|
||||||
passwordLoginConfigured: '현재 계정: {username}',
|
passwordLoginConfigured: '현재 계정: {username}',
|
||||||
|
defaultCredentialTitle: '기본 계정과 비밀번호를 변경하세요',
|
||||||
|
defaultCredentialMessage: '현재 로그인 계정이 아직 기본 사용자 이름 또는 기본 비밀번호를 사용하고 있습니다. 무단 접근을 방지하려면 현재 계정에서 사용자 이름과 비밀번호를 가능한 한 빨리 변경하세요.',
|
||||||
|
defaultCredentialAction: '변경하기',
|
||||||
|
defaultCredentialLater: '나중에 알림',
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: 'Token',
|
tokenLogin: 'Token',
|
||||||
usernamePlaceholder: 'Nome de usuario',
|
usernamePlaceholder: 'Nome de usuario',
|
||||||
passwordPlaceholder: 'Senha',
|
passwordPlaceholder: 'Senha',
|
||||||
|
defaultCredentialsHint: 'Nome de usuario padrao: admin. Senha padrao: 123456.',
|
||||||
credentialsRequired: 'Por favor, insira nome de usuario e senha',
|
credentialsRequired: 'Por favor, insira nome de usuario e senha',
|
||||||
invalidCredentials: 'Nome de usuario ou senha incorretos',
|
invalidCredentials: 'Nome de usuario ou senha incorretos',
|
||||||
tooManyAttempts: 'Muitas tentativas falhadas, por favor tente novamente mais tarde',
|
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.',
|
removeConfirm: 'Login por senha e obrigatorio para contas de usuario.',
|
||||||
passwordLoginNotConfigured: 'Login por senha nao configurado',
|
passwordLoginNotConfigured: 'Login por senha nao configurado',
|
||||||
passwordLoginConfigured: 'Conta atual: {username}',
|
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: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: '權杖登入',
|
tokenLogin: '權杖登入',
|
||||||
usernamePlaceholder: '使用者名稱',
|
usernamePlaceholder: '使用者名稱',
|
||||||
passwordPlaceholder: '密碼',
|
passwordPlaceholder: '密碼',
|
||||||
|
defaultCredentialsHint: '預設登入名:admin,預設密碼:123456',
|
||||||
credentialsRequired: '請輸入使用者名稱和密碼',
|
credentialsRequired: '請輸入使用者名稱和密碼',
|
||||||
invalidCredentials: '使用者名稱或密碼錯誤',
|
invalidCredentials: '使用者名稱或密碼錯誤',
|
||||||
tooManyAttempts: '登入失敗次數過多,請稍後再試',
|
tooManyAttempts: '登入失敗次數過多,請稍後再試',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: '使用者帳號必須保留密碼登入。',
|
removeConfirm: '使用者帳號必須保留密碼登入。',
|
||||||
passwordLoginNotConfigured: '密碼登入未設定',
|
passwordLoginNotConfigured: '密碼登入未設定',
|
||||||
passwordLoginConfigured: '目前帳號:{username}',
|
passwordLoginConfigured: '目前帳號:{username}',
|
||||||
|
defaultCredentialTitle: '請修改預設帳號和密碼',
|
||||||
|
defaultCredentialMessage: '目前登入帳號仍在使用預設使用者名稱或預設密碼。為避免未授權存取,請盡快進入目前帳號修改使用者名稱和密碼。',
|
||||||
|
defaultCredentialAction: '去修改',
|
||||||
|
defaultCredentialLater: '稍後提醒',
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default {
|
|||||||
tokenLogin: '令牌登录',
|
tokenLogin: '令牌登录',
|
||||||
usernamePlaceholder: '用户名',
|
usernamePlaceholder: '用户名',
|
||||||
passwordPlaceholder: '密码',
|
passwordPlaceholder: '密码',
|
||||||
|
defaultCredentialsHint: '默认登录名:admin,默认密码:123456',
|
||||||
credentialsRequired: '请输入用户名和密码',
|
credentialsRequired: '请输入用户名和密码',
|
||||||
invalidCredentials: '用户名或密码错误',
|
invalidCredentials: '用户名或密码错误',
|
||||||
tooManyAttempts: '登录失败次数过多,请稍后重试',
|
tooManyAttempts: '登录失败次数过多,请稍后重试',
|
||||||
@@ -37,6 +38,10 @@ export default {
|
|||||||
removeConfirm: '用户账号必须保留密码登录。',
|
removeConfirm: '用户账号必须保留密码登录。',
|
||||||
passwordLoginNotConfigured: '密码登录未配置',
|
passwordLoginNotConfigured: '密码登录未配置',
|
||||||
passwordLoginConfigured: '当前账户:{username}',
|
passwordLoginConfigured: '当前账户:{username}',
|
||||||
|
defaultCredentialTitle: '请修改默认账户和密码',
|
||||||
|
defaultCredentialMessage: '当前登录账户仍在使用默认用户名或默认密码。为了避免未授权访问,请尽快进入当前账户修改用户名和密码。',
|
||||||
|
defaultCredentialAction: '去修改',
|
||||||
|
defaultCredentialLater: '稍后提醒',
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ async function handlePasswordLogin() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="login-title">{{ t("login.title") }}</h1>
|
<h1 class="login-title">{{ t("login.title") }}</h1>
|
||||||
<p class="login-desc">{{ t("login.description") }}</p>
|
<p class="login-desc">{{ t("login.description") }}</p>
|
||||||
|
<p class="login-default-hint">{{ t("login.defaultCredentialsHint") }}</p>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="handleLogin">
|
<form class="login-form" @submit.prevent="handleLogin">
|
||||||
<input
|
<input
|
||||||
@@ -128,10 +129,17 @@ async function handlePasswordLogin() {
|
|||||||
.login-desc {
|
.login-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
margin: 0 0 32px;
|
margin: 0 0 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-default-hint {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
font-family: $font-code;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
NTabs,
|
NTabs,
|
||||||
NTabPane,
|
NTabPane,
|
||||||
@@ -22,6 +23,41 @@ import { isStoredSuperAdmin } from "@/api/client";
|
|||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const canManageUsers = isStoredSuperAdmin();
|
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(() => {
|
onMounted(() => {
|
||||||
settingsStore.fetchSettings();
|
settingsStore.fetchSettings();
|
||||||
@@ -40,7 +76,7 @@ onMounted(() => {
|
|||||||
size="large"
|
size="large"
|
||||||
:description="t('common.loading')"
|
: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')">
|
<NTabPane name="account" :tab="t('settings.tabs.account')">
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Context } from 'koa'
|
import type { Context } from 'koa'
|
||||||
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PASSWORD,
|
||||||
DEFAULT_USERNAME,
|
DEFAULT_USERNAME,
|
||||||
bootstrapDefaultSuperAdmin,
|
bootstrapDefaultSuperAdmin,
|
||||||
countActiveSuperAdmins,
|
countActiveSuperAdmins,
|
||||||
@@ -55,6 +56,7 @@ export async function currentUser(ctx: Context) {
|
|||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_at,
|
updated_at: user.updated_at,
|
||||||
last_login_at: user.last_login_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 {
|
try {
|
||||||
const result = await bridgeCleanupClient().destroyAll()
|
const result = await bridgeCleanupClient().destroyProfile(name)
|
||||||
logger.info('[switchProfile] destroyed all bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
|
logger.info('[switchProfile] destroyed bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
|
||||||
} catch (err: any) {
|
} 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 {
|
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')
|
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 () => {
|
it('shows an error when password login fails', async () => {
|
||||||
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
|
mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password'))
|
||||||
const wrapper = mount(LoginView)
|
const wrapper = mount(LoginView)
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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 = {
|
type ChildProcessMocks = {
|
||||||
execFileSync: ReturnType<typeof vi.fn>
|
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', () => {
|
describe('CLI port detection', () => {
|
||||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||||
|
const originalEnv = { ...process.env }
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv }
|
||||||
vi.doUnmock('child_process')
|
vi.doUnmock('child_process')
|
||||||
if (originalPlatform) {
|
if (originalPlatform) {
|
||||||
Object.defineProperty(process, 'platform', originalPlatform)
|
Object.defineProperty(process, 'platform', originalPlatform)
|
||||||
@@ -89,4 +104,61 @@ describe('CLI port detection', () => {
|
|||||||
8648,
|
8648,
|
||||||
)).toEqual([2468])
|
)).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 { join } from 'path'
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
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
|
// Mock hermes-cli
|
||||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
listProfiles: vi.fn(),
|
listProfiles: vi.fn(),
|
||||||
@@ -20,6 +34,27 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
|||||||
importProfile: vi.fn(),
|
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'
|
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
|
||||||
|
|
||||||
describe('Profile Routes', () => {
|
describe('Profile Routes', () => {
|
||||||
@@ -29,6 +64,9 @@ describe('Profile Routes', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
agentBridgeMocks.destroyProfile.mockResolvedValue({ destroyed: 0 })
|
||||||
|
skillInjectorMocks.injectMissingSkills.mockResolvedValue({ targets: [] })
|
||||||
|
skillInjectorMocks.resolveTargetDirForProfile.mockImplementation((name: string) => join('/tmp/hermes-skills', name))
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
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', () => {
|
describe('profile avatars', () => {
|
||||||
it('stores generated avatar metadata under the Web UI home', async () => {
|
it('stores generated avatar metadata under the Web UI home', async () => {
|
||||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-web-ui-avatar-'))
|
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(/^[^.]+\.[^.]+\.[^.]+$/)
|
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 () => {
|
it('lets super admins create regular admins with profile bindings', async () => {
|
||||||
const { users } = await initUsers()
|
const { users } = await initUsers()
|
||||||
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user