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 } }