Add user-scoped Hermes profile access
This commit is contained in:
@@ -1,24 +1,68 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials'
|
||||
import { getToken } from '../services/auth'
|
||||
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
|
||||
import {
|
||||
DEFAULT_USERNAME,
|
||||
bootstrapDefaultSuperAdmin,
|
||||
countActiveSuperAdmins,
|
||||
countUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
findFirstUser,
|
||||
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) {
|
||||
const cred = await getCredentials()
|
||||
const firstUser = findFirstUser()
|
||||
ctx.body = {
|
||||
hasPasswordLogin: !!cred,
|
||||
username: cred?.username || null,
|
||||
hasPasswordLogin: true,
|
||||
username: firstUser?.username || DEFAULT_USERNAME,
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Authenticate with username/password (public).
|
||||
* Returns the static token on success.
|
||||
* Returns a user-scoped JWT on success.
|
||||
*/
|
||||
export async function login(ctx: Context) {
|
||||
const { username, password } = ctx.request.body as { username?: string; password?: string }
|
||||
@@ -36,18 +80,24 @@ export async function login(ctx: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await verifyCredentials(username, password)
|
||||
if (!valid) {
|
||||
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
|
||||
}
|
||||
|
||||
const token = await getToken()
|
||||
if (!token) {
|
||||
let token: string
|
||||
try {
|
||||
token = await issueUserJwt(user)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Auth is disabled on this server' }
|
||||
ctx.body = { error: err?.message || 'Auth is disabled on this server' }
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,25 +110,8 @@ export async function login(ctx: Context) {
|
||||
* 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 }
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Password login is managed by user accounts' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,22 +131,15 @@ export async function changePassword(ctx: Context) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
await setCredentials(cred.username, newPassword)
|
||||
updateUserPassword(user.id, newPassword)
|
||||
ctx.body = { success: true }
|
||||
}
|
||||
|
||||
@@ -134,22 +160,22 @@ export async function changeUsername(ctx: Context) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// Update username, keep the same password
|
||||
await setCredentials(newUsername, currentPassword)
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -158,8 +184,211 @@ export async function changeUsername(ctx: Context) {
|
||||
* Remove username/password login (protected).
|
||||
*/
|
||||
export async function removePassword(ctx: Context) {
|
||||
await deleteCredentials()
|
||||
ctx.body = { success: true }
|
||||
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() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user