934 lines
34 KiB
PHP
934 lines
34 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Controllers;
|
|
|
|
use Grav\Common\User\Authentication;
|
|
use Grav\Common\User\DataUser\User as DataUser;
|
|
use Grav\Common\User\Interfaces\UserCollectionInterface;
|
|
use Grav\Common\User\Interfaces\UserInterface;
|
|
use Grav\Common\Utils;
|
|
use Grav\Framework\Flex\FlexDirectory;
|
|
use Grav\Plugin\Api\Auth\ApiKeyManager;
|
|
use Grav\Plugin\Api\Exceptions\ConflictException;
|
|
use Grav\Plugin\Api\Exceptions\ForbiddenException;
|
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\FlexBackend;
|
|
use Grav\Plugin\Api\Response\ApiResponse;
|
|
use Grav\Plugin\Api\Serializers\UserSerializer;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
class UsersController extends AbstractApiController
|
|
{
|
|
use FlexBackend;
|
|
|
|
private ?UserSerializer $serializer = null;
|
|
|
|
public function index(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
// Without api.users.read a caller can still see *their own* row —
|
|
// we auto-filter the listing to self rather than 403 the request.
|
|
// Anything beyond that requires api.users.read.
|
|
$currentUser = $this->getUser($request);
|
|
$canSeeAll = $this->isSuperAdmin($currentUser)
|
|
|| $this->hasPermission($currentUser, 'api.users.read');
|
|
|
|
if (!$canSeeAll) {
|
|
return $this->indexSelfOnly($request, $currentUser);
|
|
}
|
|
|
|
$directory = $this->getFlexDirectory('user-accounts');
|
|
if ($directory) {
|
|
return $this->indexViaFlex($request, $directory);
|
|
}
|
|
return $this->indexViaAccounts($request);
|
|
}
|
|
|
|
/**
|
|
* Single-row "listing" for callers without api.users.read. Matches the
|
|
* paginated envelope of the full listing so the client doesn't need a
|
|
* special-case branch.
|
|
*/
|
|
private function indexSelfOnly(ServerRequestInterface $request, UserInterface $currentUser): ResponseInterface
|
|
{
|
|
$pagination = $this->getPagination($request);
|
|
$data = [$this->serializeUser($currentUser)];
|
|
|
|
return ApiResponse::paginated(
|
|
data: $data,
|
|
total: 1,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/users',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List users using the Flex-Objects backend (indexed, searchable).
|
|
*/
|
|
private function indexViaFlex(ServerRequestInterface $request, FlexDirectory $directory): ResponseInterface
|
|
{
|
|
$pagination = $this->getPagination($request);
|
|
$query = $request->getQueryParams();
|
|
$search = $query['search'] ?? null;
|
|
$filters = $this->getListFilters($request);
|
|
|
|
// Grav's Flex FileStorage indexes every file in user/accounts/ without
|
|
// filtering by extension — any stray file left there by another plugin
|
|
// (e.g. revisions-pro's `name.yaml.<timestamp>.rev` snapshots) surfaces
|
|
// as a phantom user. Constrain to keys that look like actual usernames
|
|
// before the collection is built so downstream search/sort/pagination
|
|
// operate on real accounts only.
|
|
//
|
|
// Usernames may legitimately contain periods (DataUser::isValidUsername
|
|
// allows them, and so does POST /users), so we can't simply reject dots
|
|
// — that hid accounts like `bill.bailey`. Instead accept anything that
|
|
// is a valid username but drop keys that embed a stored-file extension
|
|
// (`.yaml`/`.json`), which is the tell-tale of a revision/backup stray.
|
|
$index = $directory->getIndex();
|
|
$validKeys = array_values(array_filter(
|
|
$index->getKeys(),
|
|
static fn($k) => is_string($k)
|
|
&& DataUser::isValidUsername($k)
|
|
&& !preg_match('/\.(ya?ml|json)(\.|$)/i', $k),
|
|
));
|
|
$collection = $directory->getCollection($validKeys);
|
|
|
|
// Apply search (searches username, email, fullname per blueprint config)
|
|
if ($search && $search !== '') {
|
|
$collection = $collection->search($search);
|
|
}
|
|
|
|
// Sort by username by default
|
|
$collection = $collection->sort(['username' => 'asc']);
|
|
|
|
if ($filters['access'] === '' && $filters['group'] === '') {
|
|
// No permission/group filter — keep the lazy, indexed fast path that
|
|
// only materializes the requested page.
|
|
$total = $collection->count();
|
|
$slice = $collection->slice($pagination['offset'], $pagination['limit']);
|
|
|
|
$data = [];
|
|
foreach ($slice as $flexUser) {
|
|
if ($flexUser instanceof UserInterface) {
|
|
$data[] = $this->serializeUser($flexUser);
|
|
}
|
|
}
|
|
} else {
|
|
// Permission/group filtering can't be expressed as an indexed query
|
|
// (it depends on effective access, including group inheritance and
|
|
// the superuser fallback), so materialize the ordered users and
|
|
// filter in PHP before paginating. Search above already narrowed
|
|
// the set.
|
|
$users = [];
|
|
foreach ($collection as $flexUser) {
|
|
if ($flexUser instanceof UserInterface && $this->userMatchesFilters($flexUser, $filters)) {
|
|
$users[] = $flexUser;
|
|
}
|
|
}
|
|
|
|
$total = count($users);
|
|
$data = [];
|
|
foreach (array_slice($users, $pagination['offset'], $pagination['limit']) as $flexUser) {
|
|
$data[] = $this->serializeUser($flexUser);
|
|
}
|
|
}
|
|
|
|
return ApiResponse::paginated(
|
|
data: $data,
|
|
total: $total,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/users',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* List users using filesystem scan (fallback).
|
|
*/
|
|
private function indexViaAccounts(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$pagination = $this->getPagination($request);
|
|
$query = $request->getQueryParams();
|
|
$search = isset($query['search']) ? trim((string) $query['search']) : '';
|
|
$filters = $this->getListFilters($request);
|
|
|
|
$allUsers = [];
|
|
foreach ($this->getAllUsernames() as $username) {
|
|
$user = $this->grav['accounts']->load($username);
|
|
if (!$user->exists()) {
|
|
continue;
|
|
}
|
|
if ($search !== '' && !$this->userMatchesSearch($user, $search)) {
|
|
continue;
|
|
}
|
|
if (!$this->userMatchesFilters($user, $filters)) {
|
|
continue;
|
|
}
|
|
$allUsers[] = $this->serializeUser($user);
|
|
}
|
|
|
|
$total = count($allUsers);
|
|
$paged = array_slice($allUsers, $pagination['offset'], $pagination['limit']);
|
|
|
|
return ApiResponse::paginated(
|
|
data: $paged,
|
|
total: $total,
|
|
page: $pagination['page'],
|
|
perPage: $pagination['per_page'],
|
|
baseUrl: $this->getApiBaseUrl() . '/users',
|
|
);
|
|
}
|
|
|
|
public function show(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
// Self-access mirrors update(): a user can fetch their own record
|
|
// with just api.access. Otherwise api.users.read is required to see
|
|
// someone else's account.
|
|
$currentUser = $this->getUser($request);
|
|
if ($currentUser->username !== $username) {
|
|
$this->requirePermission($request, 'api.users.read');
|
|
} else {
|
|
$this->requirePermission($request, 'api.access');
|
|
}
|
|
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$data = $this->serializeUser($user);
|
|
|
|
// ETag is computed from the user data only — system capability flags
|
|
// like twofa_global_enabled are not part of the resource state and
|
|
// shouldn't cause spurious 409s on PATCH when the admin flips the
|
|
// global setting between fetch and save.
|
|
$etag = $this->generateEtag($data);
|
|
|
|
$data['twofa_global_enabled'] = (bool) $this->config->get('plugins.login.twofa_enabled', false);
|
|
|
|
return ApiResponse::create($data, 200, ['ETag' => '"' . $etag . '"']);
|
|
}
|
|
|
|
public function create(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.write');
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['username', 'password', 'email']);
|
|
|
|
$username = $body['username'];
|
|
|
|
// Validate username format. Delegate the character rules to the core
|
|
// helper (Grav\Common\User\DataUser\User::isValidUsername) so the API
|
|
// accepts exactly what admin-classic does: letters, numbers, periods,
|
|
// hyphens and underscores, while still blocking path traversal,
|
|
// leading dots and filesystem-dangerous characters. Keep a 3-64 length
|
|
// bound for a friendlier message and to match the admin-next UI hint.
|
|
$length = mb_strlen((string) $username);
|
|
if ($length < 3 || $length > 64 || !DataUser::isValidUsername((string) $username)) {
|
|
throw new ValidationException(
|
|
'Invalid username format.',
|
|
[['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']],
|
|
);
|
|
}
|
|
|
|
/** @var UserCollectionInterface $accounts */
|
|
$accounts = $this->grav['accounts'];
|
|
$existing = $accounts->load($username);
|
|
|
|
if ($existing->exists()) {
|
|
throw new ConflictException("User '{$username}' already exists.");
|
|
}
|
|
|
|
// Create new user
|
|
$user = $accounts->load($username);
|
|
$user->set('email', $body['email']);
|
|
$user->set('fullname', $body['fullname'] ?? '');
|
|
$user->set('title', $body['title'] ?? '');
|
|
$user->set('state', $body['state'] ?? 'enabled');
|
|
$user->set('hashed_password', Authentication::create($body['password']));
|
|
$user->set('created', time());
|
|
$user->set('modified', time());
|
|
|
|
if (isset($body['access'])) {
|
|
$user->set('access', $body['access']);
|
|
}
|
|
|
|
// `groups` is super-admin-only (see update()): group membership can grant
|
|
// access, so a non-super creator must not seed group assignments.
|
|
if (isset($body['groups']) && $this->isSuperAdmin($this->getUser($request))) {
|
|
$user->set('groups', $body['groups']);
|
|
}
|
|
|
|
// Allow plugins to modify the user before save
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
|
|
|
|
// Validate the submitted fields against the account blueprint before
|
|
// writing to disk (admin2#30) — e.g. a password that fails the
|
|
// configured pwd_regex, or a required field sent empty, now returns 422.
|
|
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
|
|
|
|
$user->save();
|
|
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
|
|
$this->fireEvent('onApiUserCreated', ['user' => $user]);
|
|
|
|
return ApiResponse::created(
|
|
data: $this->serializeUser($user),
|
|
location: $this->getApiBaseUrl() . '/users/' . $username,
|
|
headers: $this->invalidationHeaders(['users:create:' . $username, 'users:list']),
|
|
);
|
|
}
|
|
|
|
public function update(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$currentUser = $this->getUser($request);
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
// Users can update themselves with just api.access, otherwise need api.users.write
|
|
$isSelf = $currentUser->username === $username;
|
|
$canManageUsers = $this->isSuperAdmin($currentUser)
|
|
|| $this->hasPermission($currentUser, 'api.users.write');
|
|
if (!$isSelf) {
|
|
$this->requirePermission($request, 'api.users.write');
|
|
} else {
|
|
// Self-edit only requires api.access (already checked by auth middleware)
|
|
$this->requirePermission($request, 'api.access');
|
|
}
|
|
|
|
// ETag validation
|
|
$currentHash = $this->generateEtag($this->serializeUser($user));
|
|
$this->validateEtag($request, $currentHash);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
|
|
if (empty($body)) {
|
|
throw new ValidationException('Request body must contain fields to update.');
|
|
}
|
|
|
|
// Privilege-sensitive fields are gated on api.users.write. Without this
|
|
// split a self-edit (api.access only) could PATCH `access` and grant
|
|
// itself api.super / admin.super — see GHSA-r945-h4vm-h736.
|
|
$selfFields = ['email', 'fullname', 'title', 'language', 'content_editor', 'twofa_enabled'];
|
|
$adminFields = ['state', 'access'];
|
|
// `groups` is marked `security@: admin.super` in the account blueprint:
|
|
// group membership can confer access, so only super admins may change it
|
|
// — a plain api.users.write manager must not assign users into groups.
|
|
$superFields = ['groups'];
|
|
$isSuper = $this->isSuperAdmin($currentUser);
|
|
|
|
if (!$canManageUsers) {
|
|
foreach ($adminFields as $field) {
|
|
if (array_key_exists($field, $body)) {
|
|
throw new ForbiddenException(
|
|
"Modifying '{$field}' requires the 'api.users.write' permission."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$isSuper) {
|
|
foreach ($superFields as $field) {
|
|
if (array_key_exists($field, $body)) {
|
|
throw new ForbiddenException(
|
|
"Modifying '{$field}' requires super-admin privileges."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$allowedFields = $selfFields;
|
|
if ($canManageUsers) {
|
|
$allowedFields = array_merge($allowedFields, $adminFields);
|
|
}
|
|
if ($isSuper) {
|
|
$allowedFields = array_merge($allowedFields, $superFields);
|
|
}
|
|
foreach ($allowedFields as $field) {
|
|
if (array_key_exists($field, $body)) {
|
|
$user->set($field, $body[$field]);
|
|
}
|
|
}
|
|
|
|
// Hash password if provided
|
|
if (isset($body['password']) && $body['password'] !== '') {
|
|
$user->set('hashed_password', Authentication::create($body['password']));
|
|
}
|
|
|
|
$user->set('modified', time());
|
|
|
|
// Allow plugins to modify the user before save
|
|
$this->fireAdminEvent('onAdminSave', ['object' => &$user]);
|
|
|
|
// Validate the submitted fields against the account blueprint before
|
|
// writing to disk (admin2#30).
|
|
$this->validateChangedFields($body, method_exists($user, 'getBlueprint') ? $user->getBlueprint() : null);
|
|
|
|
$user->save();
|
|
|
|
$this->fireAdminEvent('onAdminAfterSave', ['object' => $user]);
|
|
$this->fireEvent('onApiUserUpdated', ['user' => $user]);
|
|
|
|
return $this->respondWithEtag(
|
|
$this->serializeUser($user),
|
|
200,
|
|
['users:update:' . $username, 'users:list'],
|
|
);
|
|
}
|
|
|
|
public function delete(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.write');
|
|
|
|
$currentUser = $this->getUser($request);
|
|
$username = $this->getRouteParam($request, 'username');
|
|
|
|
if ($currentUser->username === $username) {
|
|
throw new ForbiddenException('You cannot delete your own account.');
|
|
}
|
|
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$this->fireEvent('onApiBeforeUserDelete', ['user' => $user]);
|
|
|
|
// Remove user file
|
|
$file = $user->file();
|
|
if ($file) {
|
|
$file->delete();
|
|
}
|
|
|
|
$this->fireEvent('onApiUserDeleted', ['username' => $username]);
|
|
|
|
return ApiResponse::noContent(
|
|
$this->invalidationHeaders(['users:delete:' . $username, 'users:list']),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /users/{username}/avatar - Upload a custom avatar image.
|
|
*/
|
|
public function uploadAvatar(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$currentUser = $this->getUser($request);
|
|
if ($currentUser->username !== $username) {
|
|
$this->requirePermission($request, 'api.users.write');
|
|
}
|
|
|
|
$uploadedFiles = $request->getUploadedFiles();
|
|
$file = $uploadedFiles['avatar'] ?? $uploadedFiles['file'] ?? null;
|
|
|
|
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
|
|
throw new ValidationException('No avatar file uploaded.');
|
|
}
|
|
|
|
$mime = $file->getClientMediaType() ?? '';
|
|
if (!str_starts_with($mime, 'image/')) {
|
|
throw new ValidationException('Avatar must be an image file.');
|
|
}
|
|
|
|
// Save to account://avatars/
|
|
$locator = $this->grav['locator'];
|
|
$avatarDir = $locator->findResource('account://', true) . '/avatars';
|
|
if (!is_dir($avatarDir)) {
|
|
mkdir($avatarDir, 0755, true);
|
|
}
|
|
|
|
$ext = match ($mime) {
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
default => 'jpg',
|
|
};
|
|
|
|
$filename = $username . '-' . substr(md5((string) time()), 0, 8) . '.' . $ext;
|
|
$filepath = $avatarDir . '/' . $filename;
|
|
$file->moveTo($filepath);
|
|
|
|
// Build path relative to Grav root (e.g. user/accounts/avatars/filename.jpg)
|
|
// to match the format used by the old admin plugin.
|
|
$relativeBase = $locator->findResource('account://', false);
|
|
$relativePath = $relativeBase . '/avatars/' . $filename;
|
|
|
|
// Update user's avatar reference
|
|
$user->set('avatar', [
|
|
$relativePath => [
|
|
'name' => $filename,
|
|
'type' => $mime,
|
|
'size' => filesize($filepath),
|
|
'path' => $relativePath,
|
|
],
|
|
]);
|
|
$user->save();
|
|
|
|
return ApiResponse::create(
|
|
$this->serializeUser($user),
|
|
201,
|
|
$this->invalidationHeaders(['users:update:' . $username]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* DELETE /users/{username}/avatar - Remove the custom avatar.
|
|
*/
|
|
public function deleteAvatar(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$currentUser = $this->getUser($request);
|
|
if ($currentUser->username !== $username) {
|
|
$this->requirePermission($request, 'api.users.write');
|
|
}
|
|
|
|
// Delete avatar file(s)
|
|
$avatar = $user->get('avatar');
|
|
if (is_array($avatar)) {
|
|
foreach ($avatar as $entry) {
|
|
if (is_array($entry) && isset($entry['path'])) {
|
|
// path is relative to Grav root (e.g. user/accounts/avatars/file.jpg)
|
|
$filePath = GRAV_ROOT . '/' . $entry['path'];
|
|
if (file_exists($filePath)) {
|
|
@unlink($filePath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$user->set('avatar', []);
|
|
$user->save();
|
|
|
|
return ApiResponse::create(
|
|
$this->serializeUser($user),
|
|
200,
|
|
$this->invalidationHeaders(['users:update:' . $username]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /users/{username}/2fa - Generate or regenerate 2FA secret and return QR code.
|
|
*/
|
|
public function generate2fa(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
// Self or admin
|
|
$currentUser = $this->getUser($request);
|
|
if ($currentUser->username !== $username) {
|
|
$this->requirePermission($request, 'api.users.write');
|
|
}
|
|
|
|
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
|
|
throw new \Grav\Plugin\Api\Exceptions\ApiException(
|
|
500,
|
|
'2FA Not Available',
|
|
'The Login plugin with 2FA support must be installed.'
|
|
);
|
|
}
|
|
|
|
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
|
|
$secret = $twoFa->createSecret();
|
|
|
|
// Format secret with spaces for readability
|
|
$formattedSecret = trim(chunk_split($secret, 4, ' '));
|
|
|
|
// Save to user
|
|
$user->set('twofa_secret', $formattedSecret);
|
|
// Generating/regenerating a secret resets the enabled flag — the user
|
|
// must verify a code against the new secret to re-enable.
|
|
$user->set('twofa_enabled', false);
|
|
$user->save();
|
|
|
|
// Generate QR code data URI
|
|
$qrImage = $twoFa->getQrImageData($username, $secret);
|
|
|
|
return ApiResponse::create([
|
|
'secret' => $formattedSecret,
|
|
'qr_code' => $qrImage,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /users/{username}/2fa/enable - Verify a code against the stored
|
|
* secret and set twofa_enabled=true. Self-only: only the account owner
|
|
* can enable their own 2FA, because enabling requires proving you hold
|
|
* the secret (otherwise an attacker could lock a user out by enabling
|
|
* 2FA with a secret they don't control).
|
|
*/
|
|
public function enable2fa(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$currentUser = $this->getUser($request);
|
|
if ($currentUser->username !== $username) {
|
|
throw new ForbiddenException('Only the account owner can enable 2FA.');
|
|
}
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['code']);
|
|
|
|
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
|
|
throw new \Grav\Plugin\Api\Exceptions\ApiException(
|
|
500,
|
|
'2FA Not Available',
|
|
'The Login plugin with 2FA support must be installed.',
|
|
);
|
|
}
|
|
|
|
$secret = (string) $user->get('twofa_secret');
|
|
if ($secret === '') {
|
|
throw new ValidationException('2FA secret has not been generated. POST /users/{username}/2fa first.');
|
|
}
|
|
|
|
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
|
|
if (!$twoFa->verifyCode($secret, (string) $body['code'])) {
|
|
throw new ValidationException('Invalid 2FA code.');
|
|
}
|
|
|
|
$user->set('twofa_enabled', true);
|
|
$user->save();
|
|
|
|
$this->fireEvent('onApiUser2faEnabled', ['user' => $user]);
|
|
|
|
return ApiResponse::create(['twofa_enabled' => true]);
|
|
}
|
|
|
|
/**
|
|
* POST /users/{username}/2fa/disable - Disable 2FA for a user.
|
|
*
|
|
* Self-disable requires a valid current TOTP code so that a stolen
|
|
* session cannot unilaterally remove 2FA. Admins with api.users.write
|
|
* (or superadmin) can force-disable without a code — used for lost-
|
|
* device recovery. Both paths clear twofa_secret.
|
|
*/
|
|
public function disable2fa(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$currentUser = $this->getUser($request);
|
|
$isSelf = $currentUser->username === $username;
|
|
$isAdmin = $this->isSuperAdmin($currentUser) || $this->hasPermission($currentUser, 'api.users.write');
|
|
|
|
if (!$isSelf && !$isAdmin) {
|
|
throw new ForbiddenException('You do not have permission to disable 2FA for this user.');
|
|
}
|
|
|
|
if ($isSelf && !$isAdmin) {
|
|
// Self-disable without admin privilege requires code verification.
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['code']);
|
|
|
|
if (!class_exists(\Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth::class)) {
|
|
throw new \Grav\Plugin\Api\Exceptions\ApiException(
|
|
500,
|
|
'2FA Not Available',
|
|
'The Login plugin with 2FA support must be installed.',
|
|
);
|
|
}
|
|
|
|
$secret = (string) $user->get('twofa_secret');
|
|
$twoFa = new \Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth();
|
|
if (!$secret || !$twoFa->verifyCode($secret, (string) $body['code'])) {
|
|
throw new ValidationException('Invalid 2FA code.');
|
|
}
|
|
}
|
|
|
|
$user->set('twofa_enabled', false);
|
|
$user->set('twofa_secret', '');
|
|
$user->save();
|
|
|
|
$this->fireEvent('onApiUser2faDisabled', [
|
|
'user' => $user,
|
|
'forced_by_admin' => !$isSelf,
|
|
]);
|
|
|
|
return ApiResponse::create(['twofa_enabled' => false]);
|
|
}
|
|
|
|
public function apiKeys(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$this->requireApiKeyPermission($request, $username);
|
|
|
|
$manager = new ApiKeyManager();
|
|
$keys = $manager->listKeys($user);
|
|
|
|
return ApiResponse::create($keys);
|
|
}
|
|
|
|
public function createApiKey(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$this->requireApiKeyPermission($request, $username, write: true);
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$name = $body['name'] ?? '';
|
|
$scopes = $body['scopes'] ?? [];
|
|
$expiryDays = isset($body['expiry_days']) ? (int) $body['expiry_days'] : null;
|
|
|
|
$manager = new ApiKeyManager();
|
|
$result = $manager->generateKey($user, $name, $scopes, $expiryDays);
|
|
|
|
// Return the raw key (shown ONCE only) along with key metadata
|
|
$keys = $manager->listKeys($user);
|
|
$keyMeta = null;
|
|
foreach ($keys as $key) {
|
|
if ($key['id'] === $result['id']) {
|
|
$keyMeta = $key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$data = array_merge($keyMeta ?? [], ['api_key' => $result['key']]);
|
|
|
|
return ApiResponse::created(
|
|
data: $data,
|
|
location: $this->getApiBaseUrl() . '/users/' . $username . '/api-keys',
|
|
headers: $this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
|
|
);
|
|
}
|
|
|
|
public function deleteApiKey(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$username = $this->getRouteParam($request, 'username');
|
|
$user = $this->loadUserOrFail($username);
|
|
|
|
$this->requireApiKeyPermission($request, $username, write: true);
|
|
|
|
$keyId = $this->getRouteParam($request, 'keyId');
|
|
|
|
$manager = new ApiKeyManager();
|
|
$revoked = $manager->revokeKey($user, $keyId);
|
|
|
|
if (!$revoked) {
|
|
throw new NotFoundException("API key '{$keyId}' not found for user '{$username}'.");
|
|
}
|
|
|
|
return ApiResponse::noContent(
|
|
$this->invalidationHeaders(['users:update:' . $username . ':api-keys']),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check permission for API key operations. Own user with api.access is sufficient,
|
|
* otherwise require api.users.read (or api.users.write for mutations).
|
|
*/
|
|
private function requireApiKeyPermission(
|
|
ServerRequestInterface $request,
|
|
string $targetUsername,
|
|
bool $write = false,
|
|
): void {
|
|
$currentUser = $this->getUser($request);
|
|
$isSelf = $currentUser->username === $targetUsername;
|
|
|
|
if ($isSelf) {
|
|
// Self-access only requires api.access
|
|
$this->requirePermission($request, 'api.access');
|
|
} else {
|
|
$this->requirePermission($request, $write ? 'api.users.write' : 'api.users.read');
|
|
}
|
|
}
|
|
|
|
private function loadUserOrFail(?string $username): UserInterface
|
|
{
|
|
if ($username === null || $username === '') {
|
|
throw new ValidationException('Username is required.');
|
|
}
|
|
|
|
/** @var UserCollectionInterface $accounts */
|
|
$accounts = $this->grav['accounts'];
|
|
$user = $accounts->load($username);
|
|
|
|
if (!$user->exists()) {
|
|
throw new NotFoundException("User '{$username}' not found.");
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function serializeUser(UserInterface $user): array
|
|
{
|
|
return $this->getSerializer()->serialize($user);
|
|
}
|
|
|
|
/**
|
|
* Extract the access/group list filters from the request query string.
|
|
*
|
|
* `access` is the canonical permission filter (e.g. `admin.login`,
|
|
* `api.super`); `permission` is accepted as an alias. `group` filters by
|
|
* group membership.
|
|
*
|
|
* @return array{access: string, group: string}
|
|
*/
|
|
private function getListFilters(ServerRequestInterface $request): array
|
|
{
|
|
$query = $request->getQueryParams();
|
|
$access = $query['access'] ?? $query['permission'] ?? '';
|
|
$group = $query['group'] ?? '';
|
|
|
|
return [
|
|
'access' => is_string($access) ? trim($access) : '',
|
|
'group' => is_string($group) ? trim($group) : '',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{access: string, group: string} $filters
|
|
*/
|
|
private function userMatchesFilters(UserInterface $user, array $filters): bool
|
|
{
|
|
if ($filters['group'] !== '') {
|
|
$groups = array_map('strval', (array) $user->get('groups', []));
|
|
if (!in_array($filters['group'], $groups, true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if ($filters['access'] !== '' && !$this->userHasEffectiveAccess($user, $filters['access'])) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Test whether a user is effectively granted a permission, independent of
|
|
* login state (so it works against accounts loaded from storage).
|
|
*
|
|
* Resolves the action against the merged access map (group access overlaid
|
|
* by the user's own access) with parent-key inheritance — `api.pages`
|
|
* covers `api.pages.read` — and treats super admins (api.super or the
|
|
* legacy admin.super) as authorized for everything, so "find all admins"
|
|
* catches either authority.
|
|
*/
|
|
private function userHasEffectiveAccess(UserInterface $user, string $action): bool
|
|
{
|
|
if ($action === '') {
|
|
return true;
|
|
}
|
|
|
|
$flat = $this->effectiveAccessMap($user);
|
|
|
|
if ($action !== 'admin.super' && $action !== 'api.super') {
|
|
if ($this->isPositiveFlat($flat, 'api.super') || $this->isPositiveFlat($flat, 'admin.super')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Walk up the dot-path; the closest explicitly-set key wins.
|
|
$key = $action;
|
|
while ($key !== '') {
|
|
if (array_key_exists($key, $flat)) {
|
|
return Utils::isPositive($flat[$key]);
|
|
}
|
|
$pos = strrpos($key, '.');
|
|
$key = $pos !== false ? substr($key, 0, $pos) : '';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build a flattened (dot-notation) access map for the user: each group's
|
|
* access first, then the user's own access on top so direct grants
|
|
* override inherited ones.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function effectiveAccessMap(UserInterface $user): array
|
|
{
|
|
$map = [];
|
|
|
|
foreach ((array) $user->get('groups', []) as $group) {
|
|
if (!is_string($group)) {
|
|
continue;
|
|
}
|
|
$groupAccess = $this->config->get("groups.{$group}.access");
|
|
if (is_array($groupAccess)) {
|
|
$map = array_merge($map, Utils::arrayFlattenDotNotation($groupAccess));
|
|
}
|
|
}
|
|
|
|
$own = $user->get('access');
|
|
if (is_array($own)) {
|
|
$map = array_merge($map, Utils::arrayFlattenDotNotation($own));
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $flat
|
|
*/
|
|
private function isPositiveFlat(array $flat, string $key): bool
|
|
{
|
|
return array_key_exists($key, $flat) && Utils::isPositive($flat[$key]);
|
|
}
|
|
|
|
/**
|
|
* Case-insensitive substring match across the searchable user fields,
|
|
* mirroring the Flex backend's blueprint-configured search.
|
|
*/
|
|
private function userMatchesSearch(UserInterface $user, string $search): bool
|
|
{
|
|
$needle = mb_strtolower($search);
|
|
$haystacks = [
|
|
(string) $user->username,
|
|
(string) $user->get('email', ''),
|
|
(string) $user->get('fullname', ''),
|
|
(string) $user->get('title', ''),
|
|
];
|
|
|
|
foreach ($haystacks as $value) {
|
|
if ($value !== '' && str_contains(mb_strtolower($value), $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function getSerializer(): UserSerializer
|
|
{
|
|
return $this->serializer ??= new UserSerializer();
|
|
}
|
|
|
|
/**
|
|
* Get all usernames by scanning account files.
|
|
*/
|
|
private function getAllUsernames(): array
|
|
{
|
|
$locator = $this->grav['locator'];
|
|
|
|
$accountDir = $locator->findResource('account://', true)
|
|
?: $locator->findResource('user://accounts', true);
|
|
|
|
if (!$accountDir || !is_dir($accountDir)) {
|
|
return [];
|
|
}
|
|
|
|
$usernames = [];
|
|
foreach (new \DirectoryIterator($accountDir) as $file) {
|
|
if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') {
|
|
continue;
|
|
}
|
|
$usernames[] = $file->getBasename('.yaml');
|
|
}
|
|
|
|
sort($usernames);
|
|
return $usernames;
|
|
}
|
|
}
|