- Add username/password login as additional auth mechanism alongside existing token - First login must use token; password can be configured in Settings > Account - Password login returns the existing static token (no auth middleware changes) - Add account settings: setup, change password, change username, remove password - Add logout button to sidebar footer - Add version changelog popup (click version number in sidebar) - Support all 8 locales (en, zh, de, es, fr, ja, ko, pt) - Bump version to 0.4.3 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
|
||||
import { getToken } from '../services/auth'
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Check if username/password login is configured (public).
|
||||
*/
|
||||
export async function authStatus(ctx: Context) {
|
||||
const cred = await getCredentials()
|
||||
ctx.body = {
|
||||
hasPasswordLogin: !!cred,
|
||||
username: cred?.username || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate with username/password (public).
|
||||
* Returns the static token 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 valid = await verifyCredentials(username, password)
|
||||
if (!valid) {
|
||||
ctx.status = 401
|
||||
ctx.body = { error: 'Invalid username or password' }
|
||||
return
|
||||
}
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Auth is disabled on this server' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = { token }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/setup
|
||||
* Set up username/password (protected).
|
||||
*/
|
||||
export async function setupPassword(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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
await setCredentials(username, password)
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cred = await getCredentials()
|
||||
if (!cred) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login not configured' }
|
||||
return
|
||||
}
|
||||
|
||||
// Verify current password — use the username from stored credentials
|
||||
const valid = await verifyCredentials(cred.username, currentPassword)
|
||||
if (!valid) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Current password is incorrect' }
|
||||
return
|
||||
}
|
||||
|
||||
await setCredentials(cred.username, 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 cred = await getCredentials()
|
||||
if (!cred) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login not configured' }
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await verifyCredentials(cred.username, currentPassword)
|
||||
if (!valid) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Current password is incorrect' }
|
||||
return
|
||||
}
|
||||
|
||||
// Update username, keep the same password
|
||||
await setCredentials(newUsername, currentPassword)
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/password
|
||||
* Remove username/password login (protected).
|
||||
*/
|
||||
export async function removePassword(ctx: Context) {
|
||||
await deleteCredentials()
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../controllers/auth'
|
||||
|
||||
// Public routes (no auth required)
|
||||
export const authPublicRoutes = new Router()
|
||||
authPublicRoutes.get('/api/auth/status', ctrl.authStatus)
|
||||
authPublicRoutes.post('/api/auth/login', ctrl.login)
|
||||
|
||||
// Protected routes (auth required)
|
||||
export const authProtectedRoutes = new Router()
|
||||
authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword)
|
||||
authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword)
|
||||
authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername)
|
||||
authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword)
|
||||
@@ -5,6 +5,7 @@ import { healthRoutes } from './health'
|
||||
import { webhookRoutes } from './webhook'
|
||||
import { uploadRoutes } from './upload'
|
||||
import { updateRoutes } from './update'
|
||||
import { authPublicRoutes, authProtectedRoutes } from './auth'
|
||||
|
||||
// Hermes route modules
|
||||
import { sessionRoutes } from './hermes/sessions'
|
||||
@@ -29,11 +30,13 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
// --- Public routes (no auth required) ---
|
||||
app.use(healthRoutes.routes())
|
||||
app.use(webhookRoutes.routes())
|
||||
app.use(authPublicRoutes.routes())
|
||||
|
||||
// --- Auth middleware: all routes below require authentication ---
|
||||
app.use(requireAuth)
|
||||
|
||||
// --- Protected routes (auth required) ---
|
||||
app.use(authProtectedRoutes.routes())
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
app.use(sessionRoutes.routes())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { scryptSync, randomBytes } from 'node:crypto'
|
||||
|
||||
const APP_HOME = join(homedir(), '.hermes-web-ui')
|
||||
const CREDENTIALS_FILE = join(APP_HOME, '.credentials')
|
||||
|
||||
export interface Credentials {
|
||||
username: string
|
||||
password_hash: string
|
||||
salt: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
||||
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
return scryptSync(password, salt, 64, SCRYPT_OPTIONS).toString('hex')
|
||||
}
|
||||
|
||||
export async function getCredentials(): Promise<Credentials | null> {
|
||||
try {
|
||||
const data = await readFile(CREDENTIALS_FILE, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCredentials(username: string, password: string): Promise<Credentials> {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const password_hash = hashPassword(password, salt)
|
||||
const cred: Credentials = { username, password_hash, salt, created_at: Date.now() }
|
||||
await mkdir(APP_HOME, { recursive: true })
|
||||
await writeFile(CREDENTIALS_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 })
|
||||
return cred
|
||||
}
|
||||
|
||||
export async function deleteCredentials(): Promise<void> {
|
||||
try {
|
||||
await unlink(CREDENTIALS_FILE)
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
|
||||
const cred = await getCredentials()
|
||||
if (!cred) return false
|
||||
if (cred.username !== username) return false
|
||||
const computed = hashPassword(password, cred.salt)
|
||||
return computed === cred.password_hash
|
||||
}
|
||||
|
||||
export function credentialsFileExists(): boolean {
|
||||
return existsSync(CREDENTIALS_FILE)
|
||||
}
|
||||
Reference in New Issue
Block a user