Add default credential reset safeguards

This commit is contained in:
ekko
2026-05-24 09:49:21 +08:00
committed by ekko
parent 9708a6a521
commit f8a1b2f6ae
22 changed files with 565 additions and 7 deletions
+104 -1
View File
@@ -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,
}
+2
View File
@@ -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>
+1
View File
@@ -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>
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+5
View File
@@ -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: {
+9 -1
View File
@@ -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>
+2
View File
@@ -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()
})
})
+6
View File
@@ -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)
+72
View File
@@ -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 })
}
})
})
+75
View File
@@ -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-'))
+24
View File
@@ -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', () => ({