6647dc9bc8
The authStatus() controller previously returned the first users username to unauthenticated clients. The frontend never used this value — `fetchAuthStatus()` in LoginView.vue discards the return value entirely. Remove the field to prevent username enumeration. Changes: - server: drop `username` from authStatus response body - server: remove unused `findFirstUser` import - client: remove `username` from AuthStatus interface
422 lines
12 KiB
TypeScript
422 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: 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 }
|
|
}
|