421 lines
16 KiB
PHP
421 lines
16 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\Plugin\Api\Auth\JwtAuthenticator;
|
|
use Grav\Plugin\Api\Exceptions\ApiException;
|
|
use Grav\Plugin\Api\Exceptions\ConflictException;
|
|
use Grav\Plugin\Api\Exceptions\NotFoundException;
|
|
use Grav\Plugin\Api\Exceptions\ValidationException;
|
|
use Grav\Plugin\Api\Invitations\InviteStore;
|
|
use Grav\Plugin\Api\Response\ApiResponse;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
/**
|
|
* User invitations.
|
|
*
|
|
* An admin pre-configures a new user's permissions/groups and sends a
|
|
* time-limited invite link. The recipient opens the link, chooses their own
|
|
* username/fullname/title/password, and the account is created with exactly
|
|
* the access the admin pre-set — never more. Because the invitee never picks
|
|
* their own access, they cannot make themselves a super admin.
|
|
*
|
|
* Admin endpoints require api.users.write (list requires api.users.read).
|
|
* The accept/validate endpoints live under /auth/ so they are public.
|
|
*/
|
|
class InvitationsController extends AbstractApiController
|
|
{
|
|
use ResolvesAdminBaseUrl;
|
|
|
|
private ?InviteStore $store = null;
|
|
|
|
private function store(): InviteStore
|
|
{
|
|
return $this->store ??= new InviteStore();
|
|
}
|
|
|
|
/**
|
|
* GET /invitations — list pending (non-expired) invites.
|
|
*/
|
|
public function index(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.read');
|
|
|
|
$store = $this->store();
|
|
$store->purgeExpired();
|
|
|
|
$data = [];
|
|
foreach ($store->all() as $record) {
|
|
$data[] = $this->serializeInvite($record);
|
|
}
|
|
|
|
// Most-recent first.
|
|
usort($data, static fn($a, $b) => ($b['created'] ?? 0) <=> ($a['created'] ?? 0));
|
|
|
|
return ApiResponse::create(['invitations' => $data]);
|
|
}
|
|
|
|
/**
|
|
* POST /invitations — create an invite and (if email is configured) send it.
|
|
*/
|
|
public function create(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.write');
|
|
|
|
$actor = $this->getUser($request);
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['email']);
|
|
|
|
$email = trim((string) $body['email']);
|
|
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
|
throw new ValidationException(
|
|
'Invalid email address.',
|
|
[['field' => 'email', 'message' => 'A valid email address is required.']],
|
|
);
|
|
}
|
|
|
|
/** @var UserCollectionInterface $accounts */
|
|
$accounts = $this->grav['accounts'];
|
|
$existing = $accounts->find($email, ['email']);
|
|
if ($existing && $existing->exists()) {
|
|
throw new ConflictException('A user with that email already exists.');
|
|
}
|
|
|
|
// Permissions the invitee will receive. Strip super flags unless the
|
|
// inviting admin is itself super — an admin cannot grant authority it
|
|
// does not hold, and this is the core "can't make yourself super" gate.
|
|
$access = is_array($body['access'] ?? null) ? $body['access'] : [];
|
|
if (!$this->isSuperAdmin($actor)) {
|
|
$access = $this->stripSuperFlags($access);
|
|
}
|
|
|
|
$groups = [];
|
|
if (is_array($body['groups'] ?? null)) {
|
|
$groups = array_values(array_filter(
|
|
$body['groups'],
|
|
static fn($g) => is_string($g) && $g !== '',
|
|
));
|
|
}
|
|
|
|
// Expiration: clamp to a sane window; default 7 days.
|
|
$default = (int) $this->config->get('plugins.api.invitations.expiration', 604800);
|
|
$expiration = (int) ($body['expiration'] ?? $default);
|
|
if ($expiration < 300) {
|
|
$expiration = $default;
|
|
}
|
|
|
|
$store = $this->store();
|
|
|
|
// One pending invite per email — replace any prior one.
|
|
$prior = $store->getByEmail($email);
|
|
if ($prior && isset($prior['token'])) {
|
|
$store->remove((string) $prior['token']);
|
|
}
|
|
|
|
$token = $store->generateToken();
|
|
$record = [
|
|
'token' => $token,
|
|
'email' => $email,
|
|
'fullname' => trim((string) ($body['fullname'] ?? '')),
|
|
'access' => $access,
|
|
'groups' => $groups,
|
|
'created' => time(),
|
|
'created_by' => (string) $actor->username,
|
|
'created_by_name' => (string) ($actor->get('fullname') ?: $actor->username),
|
|
'expires' => time() + $expiration,
|
|
];
|
|
$store->add($record);
|
|
|
|
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
|
|
|
|
// Email guard mirrors AuthController::forgotPassword. If email isn't
|
|
// configured we still create the invite and hand the link back so the
|
|
// admin can deliver it manually — never silently fail.
|
|
$emailSent = false;
|
|
$warning = null;
|
|
if (isset($this->grav['Email']) && !empty($this->config->get('plugins.email.from'))) {
|
|
try {
|
|
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
|
|
$emailSent = true;
|
|
} catch (\Throwable $e) {
|
|
$this->grav['log']->error('api.invitations: failed to send invite email: ' . $e->getMessage());
|
|
$warning = 'The invitation was created but the email could not be sent. Share the link manually.';
|
|
}
|
|
} else {
|
|
$warning = 'Email is not configured, so no invitation email was sent. Share the link manually.';
|
|
}
|
|
|
|
$payload = $this->serializeInvite($record);
|
|
$payload['link'] = $link;
|
|
$payload['email_sent'] = $emailSent;
|
|
if ($warning !== null) {
|
|
$payload['warning'] = $warning;
|
|
}
|
|
|
|
return ApiResponse::created(
|
|
data: $payload,
|
|
location: $this->getApiBaseUrl() . '/invitations/' . $token,
|
|
headers: $this->invalidationHeaders(['invitations:list']),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /invitations/{token}/resend — re-send an existing invite's email.
|
|
*/
|
|
public function resend(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.write');
|
|
|
|
$token = (string) $this->getRouteParam($request, 'token');
|
|
$record = $this->store()->get($token);
|
|
if ($record === null || InviteStore::isExpired($record)) {
|
|
throw new NotFoundException('Invitation not found or expired.');
|
|
}
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$link = $this->buildInviteLink($body['admin_base_url'] ?? null, $request, $token);
|
|
|
|
if (!isset($this->grav['Email']) || empty($this->config->get('plugins.email.from'))) {
|
|
throw new ApiException(422, 'Unprocessable Entity', 'Email is not configured. Share the invite link manually.');
|
|
}
|
|
|
|
$actor = $this->getUser($request);
|
|
$this->sendInviteEmail($record, $link, $actor, (string) ($body['message'] ?? ''));
|
|
|
|
$payload = $this->serializeInvite($record);
|
|
$payload['link'] = $link;
|
|
$payload['email_sent'] = true;
|
|
|
|
return ApiResponse::create($payload);
|
|
}
|
|
|
|
/**
|
|
* DELETE /invitations/{token} — revoke an invite.
|
|
*/
|
|
public function delete(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$this->requirePermission($request, 'api.users.write');
|
|
|
|
$token = (string) $this->getRouteParam($request, 'token');
|
|
if (!$this->store()->remove($token)) {
|
|
throw new NotFoundException('Invitation not found.');
|
|
}
|
|
|
|
return $this->respondWithInvalidation(null, ['invitations:list'], 204);
|
|
}
|
|
|
|
/**
|
|
* GET /auth/invite/{token} — PUBLIC. Validate a token for the accept page.
|
|
*
|
|
* Returns only what the accept form needs (email to lock, optional
|
|
* fullname prefill, validity). Never leaks the pre-set access/groups.
|
|
*/
|
|
public function validate(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$token = (string) $this->getRouteParam($request, 'token');
|
|
$record = $this->store()->get($token);
|
|
|
|
if ($record === null) {
|
|
throw new NotFoundException('This invitation is invalid.');
|
|
}
|
|
|
|
if (InviteStore::isExpired($record)) {
|
|
return ApiResponse::create([
|
|
'valid' => false,
|
|
'expired' => true,
|
|
'email' => (string) ($record['email'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
return ApiResponse::create([
|
|
'valid' => true,
|
|
'expired' => false,
|
|
'email' => (string) ($record['email'] ?? ''),
|
|
'fullname' => (string) ($record['fullname'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /auth/invite/{token} — PUBLIC. Accept an invite: create the account
|
|
* with the admin-preset access/groups and auto-login.
|
|
*/
|
|
public function accept(ServerRequestInterface $request): ResponseInterface
|
|
{
|
|
$token = (string) $this->getRouteParam($request, 'token');
|
|
$store = $this->store();
|
|
$record = $store->get($token);
|
|
|
|
if ($record === null) {
|
|
throw new NotFoundException('This invitation is invalid.');
|
|
}
|
|
if (InviteStore::isExpired($record)) {
|
|
$store->remove($token);
|
|
throw new ApiException(410, 'Gone', 'This invitation has expired.');
|
|
}
|
|
|
|
$body = $this->getRequestBody($request);
|
|
$this->requireFields($body, ['username', 'password']);
|
|
|
|
$username = (string) $body['username'];
|
|
$password = (string) $body['password'];
|
|
|
|
// Username format — identical rules to UsersController::create.
|
|
$length = mb_strlen($username);
|
|
if ($length < 3 || $length > 64 || !DataUser::isValidUsername($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).']],
|
|
);
|
|
}
|
|
|
|
// Password policy — mirror SetupController.
|
|
$pwdRegex = (string) $this->config->get('system.pwd_regex', '');
|
|
if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) {
|
|
throw new ValidationException(
|
|
'Password does not meet the required policy.',
|
|
[['field' => 'password', 'message' => 'Password does not meet the required policy.']],
|
|
);
|
|
}
|
|
if ($pwdRegex === '' && strlen($password) < 8) {
|
|
throw new ValidationException(
|
|
'Password is too short.',
|
|
[['field' => 'password', 'message' => 'Password must be at least 8 characters.']],
|
|
);
|
|
}
|
|
|
|
/** @var UserCollectionInterface $accounts */
|
|
$accounts = $this->grav['accounts'];
|
|
|
|
if ($accounts->load($username)->exists()) {
|
|
throw new ConflictException("User '{$username}' already exists.");
|
|
}
|
|
|
|
$user = $accounts->load($username);
|
|
// Email is locked to the invited address — the token is bound to it.
|
|
$user->set('email', (string) ($record['email'] ?? ''));
|
|
$user->set('fullname', trim((string) ($body['fullname'] ?? ($record['fullname'] ?? ''))));
|
|
$user->set('title', trim((string) ($body['title'] ?? '')));
|
|
$user->set('state', 'enabled');
|
|
$user->set('hashed_password', Authentication::create($password));
|
|
$user->set('created', time());
|
|
$user->set('modified', time());
|
|
// Access + groups come from the invite, NOT the request body — the
|
|
// invitee can never influence their own permissions.
|
|
$user->set('access', is_array($record['access'] ?? null) ? $record['access'] : []);
|
|
if (!empty($record['groups']) && is_array($record['groups'])) {
|
|
$user->set('groups', array_values($record['groups']));
|
|
}
|
|
// Fresh-account hygiene (matches SetupController).
|
|
$user->set('avatar', []);
|
|
$user->set('twofa_enabled', false);
|
|
$user->set('twofa_secret', '');
|
|
|
|
// NOTE: unlike the authenticated UsersController::create, this is a
|
|
// PUBLIC endpoint, so the router has not registered the admin proxy
|
|
// ($grav['admin']) that onAdminSave/onAdminAfterSave subscribers
|
|
// (git-sync, SEO, etc.) rely on — firing them here fatals. We follow
|
|
// the same convention as the public SetupController: save the account
|
|
// and fire only the API-level events.
|
|
$user->save();
|
|
$this->fireEvent('onApiUserCreated', ['user' => $user]);
|
|
$this->fireEvent('onApiInvitationAccepted', ['user' => $user, 'invitation' => $record]);
|
|
|
|
$store->remove($token);
|
|
|
|
// Auto-login the new user (same token pair as /auth/setup).
|
|
$jwt = new JwtAuthenticator($this->grav, $this->config);
|
|
$response = $this->issueTokenPair($jwt, $user);
|
|
|
|
return $response->withHeader('X-Invalidates', 'users:list');
|
|
}
|
|
|
|
/**
|
|
* Strip super-admin flags from an access tree.
|
|
*
|
|
* @param array<string, mixed> $access
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function stripSuperFlags(array $access): array
|
|
{
|
|
foreach (['admin', 'api'] as $scope) {
|
|
if (isset($access[$scope]) && is_array($access[$scope])) {
|
|
unset($access[$scope]['super']);
|
|
}
|
|
}
|
|
return $access;
|
|
}
|
|
|
|
private function buildInviteLink(mixed $clientBaseUrl, ServerRequestInterface $request, string $token): string
|
|
{
|
|
$adminBase = $this->resolveAdminBaseUrl($clientBaseUrl, $request, ['/users/invite', '/invite']);
|
|
return rtrim($adminBase, '/') . '/invite?token=' . rawurlencode($token);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $record
|
|
*/
|
|
private function sendInviteEmail(array $record, string $link, UserInterface $actor, string $message = ''): void
|
|
{
|
|
if (!isset($this->grav['Email'])) {
|
|
throw new \RuntimeException('Email service not available.');
|
|
}
|
|
|
|
$cfg = $this->grav['config'];
|
|
$siteHost = (string) ($cfg->get('plugins.login.site_host') ?: ($this->grav['uri']->host() ?? ''));
|
|
|
|
$context = [
|
|
'invite_link' => $link,
|
|
'actor' => (string) ($record['created_by_name'] ?? $actor->get('fullname') ?: $actor->username),
|
|
'message' => $message,
|
|
'site_name' => $cfg->get('site.title', 'Website'),
|
|
'site_host' => $siteHost,
|
|
'author' => $cfg->get('site.author.name', ''),
|
|
];
|
|
|
|
$params = [
|
|
'to' => (string) ($record['email'] ?? ''),
|
|
'body' => [
|
|
[
|
|
'content_type' => 'text/html',
|
|
'template' => 'emails/api/invite-user.html.twig',
|
|
'body' => '',
|
|
],
|
|
],
|
|
];
|
|
|
|
/** @var \Grav\Plugin\Email\Email $email */
|
|
$email = $this->grav['Email'];
|
|
$emailMessage = $email->buildMessage($params, $context);
|
|
$email->send($emailMessage);
|
|
}
|
|
|
|
/**
|
|
* Public-safe invite representation (no access/groups leakage beyond what
|
|
* an authenticated admin endpoint returns).
|
|
*
|
|
* @param array<string, mixed> $record
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function serializeInvite(array $record): array
|
|
{
|
|
return [
|
|
'token' => (string) ($record['token'] ?? ''),
|
|
'email' => (string) ($record['email'] ?? ''),
|
|
'fullname' => (string) ($record['fullname'] ?? ''),
|
|
'groups' => array_values((array) ($record['groups'] ?? [])),
|
|
'created' => (int) ($record['created'] ?? 0),
|
|
'created_by' => (string) ($record['created_by'] ?? ''),
|
|
'created_by_name' => (string) ($record['created_by_name'] ?? ''),
|
|
'expires' => (int) ($record['expires'] ?? 0),
|
|
'expired' => InviteStore::isExpired($record),
|
|
];
|
|
}
|
|
}
|