Files
Hermes-ui/packages/server/src/controllers/auth.ts
T
sir1st cbae8e8c3f Add desktop (Electron) packaging and release distribution (#1147)
* Add desktop packaging workflow

* Add desktop package homepage

* Fix desktop default credential prompt

* Suppress default credential prompt on desktop

* Publish desktop artifacts on release; reduce CI to PR smoke test

Add desktop-release.yml triggered on release publish (mirroring
docker-publish.yml) to build all platforms and upload .dmg/.exe/
.AppImage/.deb to the GitHub Release.

Trim build.yml desktop job to a PR-only Linux x64 smoke test, since
release artifacts are now produced by desktop-release.yml. This drops
per-push and macOS/Windows packaging from regular CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix desktop Hermes data home on Windows

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:20:04 +08:00

424 lines
12 KiB
TypeScript

import type { Context } from 'koa'
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
import {
DEFAULT_PASSWORD,
DEFAULT_USERNAME,
bootstrapDefaultSuperAdmin,
countActiveSuperAdmins,
countUsers,
createUser,
deleteUser,
findUserById,
findUserByUsername,
listUsers,
updateUser,
updateUsername,
updateUserPassword,
verifyPassword,
type UserRole,
type UserStatus,
} from '../db/hermes/users-store'
import { issueUserJwt } from '../middleware/user-auth'
import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile'
/**
* GET /api/auth/status
* Check if username/password login is configured (public).
*/
export async function authStatus(ctx: Context) {
ctx.body = {
hasPasswordLogin: true,
hasUsers: countUsers() > 0,
}
}
/**
* GET /api/auth/me
* Return the authenticated account.
*/
export async function currentUser(ctx: Context) {
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
ctx.body = {
user: {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
created_at: user.created_at,
updated_at: user.updated_at,
last_login_at: user.last_login_at,
requiresCredentialChange: process.env.HERMES_DESKTOP === 'true'
? false
: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
},
}
}
/**
* POST /api/auth/login
* Authenticate with username/password (public).
* Returns a user-scoped JWT on success.
*/
export async function login(ctx: Context) {
const { username, password } = ctx.request.body as { username?: string; password?: string }
if (!username || !password) {
ctx.status = 400
ctx.body = { error: 'Username and password are required' }
return
}
const ip = extractIp(ctx)
const result = checkPassword(ip)
if (!result.allowed) {
ctx.status = result.status
ctx.body = { error: 'Too many login attempts, please try again later' }
return
}
const existingUserCount = countUsers()
const user = existingUserCount === 0
? bootstrapDefaultSuperAdmin(username, password)
: findUserByUsername(username)
if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) {
recordPasswordFailure(ip)
ctx.status = 401
ctx.body = { error: 'Invalid username or password' }
return
}
let token: string
try {
token = await issueUserJwt(user)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err?.message || 'Failed to issue login token' }
return
}
recordPasswordSuccess(ip)
ctx.body = { token }
}
/**
* POST /api/auth/setup
* Set up username/password (protected).
*/
export async function setupPassword(ctx: Context) {
ctx.status = 400
ctx.body = { error: 'Password login is managed by user accounts' }
}
/**
* POST /api/auth/change-password
* Change password (protected).
*/
export async function changePassword(ctx: Context) {
const { currentPassword, newPassword } = ctx.request.body as { currentPassword?: string; newPassword?: string }
if (!currentPassword || !newPassword) {
ctx.status = 400
ctx.body = { error: 'Current password and new password are required' }
return
}
if (newPassword.length < 6) {
ctx.status = 400
ctx.body = { error: 'New password must be at least 6 characters' }
return
}
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
updateUserPassword(user.id, newPassword)
ctx.body = { success: true }
}
/**
* POST /api/auth/change-username
* Change username (protected).
*/
export async function changeUsername(ctx: Context) {
const { currentPassword, newUsername } = ctx.request.body as { currentPassword?: string; newUsername?: string }
if (!currentPassword || !newUsername) {
ctx.status = 400
ctx.body = { error: 'Current password and new username are required' }
return
}
if (newUsername.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
const existing = findUserByUsername(newUsername)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
updateUsername(user.id, newUsername)
ctx.body = { success: true }
}
/**
* DELETE /api/auth/password
* Remove username/password login (protected).
*/
export async function removePassword(ctx: Context) {
ctx.status = 400
ctx.body = { error: 'Password login cannot be removed for user accounts' }
}
function normalizeRole(value: unknown): UserRole | null {
return value === 'super_admin' || value === 'admin' ? value : null
}
function normalizeStatus(value: unknown): UserStatus | null {
return value === 'active' || value === 'disabled' ? value : null
}
function normalizeProfiles(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
}
function validateProfiles(profiles: string[]): string | null {
const available = new Set(listProfileNamesFromDisk())
const missing = profiles.find(profile => !available.has(profile))
return missing || null
}
/**
* GET /api/auth/users
* Super admin user management list.
*/
export async function listManagedUsers(ctx: Context) {
ctx.body = {
users: listUsers(),
profiles: listProfileNamesFromDisk(),
}
}
/**
* POST /api/auth/users
* Create a user account. Super admin only.
*/
export async function createManagedUser(ctx: Context) {
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = String(body.username || '').trim()
const password = String(body.password || '')
const role = normalizeRole(body.role || 'admin')
const status = normalizeStatus(body.status || 'active')
const profiles = normalizeProfiles(body.profiles)
if (username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (!role || !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (findUserByUsername(username)) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
const user = createUser({
username,
password,
role,
status,
profiles: role === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.status = 201
ctx.body = { user, users: listUsers() }
}
/**
* PUT /api/auth/users/:id
* Update user account metadata, password, and profile bindings.
*/
export async function updateManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = body.username == null ? undefined : String(body.username).trim()
const password = body.password == null ? undefined : String(body.password)
const role = body.role == null ? undefined : normalizeRole(body.role)
const status = body.status == null ? undefined : normalizeStatus(body.status)
const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles)
if (username !== undefined && username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password !== undefined && password.length > 0 && password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (body.role != null && !role || body.status != null && !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (username && username !== user.username) {
const existing = findUserByUsername(username)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
}
const nextRole = role || user.role
const nextStatus = status || user.status
const currentUserId = ctx.state.user?.id
if (user.id === currentUserId && nextStatus !== 'active') {
ctx.status = 400
ctx.body = { error: 'You cannot disable your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
if (profiles) {
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
}
updateUser({
userId: user.id,
username,
password: password || undefined,
role: role || undefined,
status: status || undefined,
profiles: nextRole === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.body = { user: findUserById(user.id), users: listUsers() }
}
/**
* DELETE /api/auth/users/:id
* Delete a user account. Super admin only.
*/
export async function deleteManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
if (ctx.state.user?.id === user.id) {
ctx.status = 400
ctx.body = { error: 'You cannot delete your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
deleteUser(user.id)
ctx.body = { success: true, users: listUsers() }
}
/**
* GET /api/auth/locked-ips
* List all currently locked IPs (protected).
*/
export async function listLockedIps(ctx: Context) {
const locks = getLockedIps()
ctx.body = { locks }
}
/**
* DELETE /api/auth/locked-ips?ip=xxx
* Unlock a specific IP. No ip param = unlock all.
*/
export async function unlockIpHandler(ctx: Context) {
const ip = ctx.query.ip as string
if (ip) {
const found = unlockIp(ip)
if (!found) {
ctx.status = 404
ctx.body = { error: 'IP not locked' }
return
}
ctx.body = { success: true }
return
}
// No IP specified — unlock all
const count = unlockAll()
ctx.body = { success: true, count }
}