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
@@ -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', () => ({