428 lines
16 KiB
PHP
428 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App;
|
|
|
|
final class RegistrationService
|
|
{
|
|
private RegistrationStore $store;
|
|
private RegistrationLdifBuilder $ldifBuilder;
|
|
private RegistrationSecretBox $secretBox;
|
|
private LdapProvisioner $ldapProvisioner;
|
|
private RegistrationIdentityAllocator $identityAllocator;
|
|
private PasswordResetService $passwordResetService;
|
|
|
|
/**
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public function __construct(
|
|
private readonly string $projectRoot,
|
|
private readonly array $config,
|
|
) {
|
|
$storagePath = $this->resolveProjectPath((string) ($this->config['storage_path'] ?? 'data/registration-requests.json'));
|
|
$this->store = new RegistrationStore($storagePath);
|
|
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
|
|
$this->secretBox = new RegistrationSecretBox((string) ($this->config['password_crypto_key'] ?? ''));
|
|
$this->ldapProvisioner = new LdapProvisioner($this->config);
|
|
$counterPath = $this->resolveProjectPath((string) ($this->config['identity_counter_path'] ?? 'data/registration-identity-counter.json'));
|
|
$this->identityAllocator = new RegistrationIdentityAllocator($counterPath, $this->config);
|
|
$this->passwordResetService = new PasswordResetService($this->projectRoot, $this->config);
|
|
}
|
|
|
|
public function isEnabled(): bool
|
|
{
|
|
return (bool) ($this->config['enabled'] ?? false);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function all(): array
|
|
{
|
|
return $this->store->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function find(string $id): ?array
|
|
{
|
|
return $this->store->find($id);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function findLatestByUsername(string $username): ?array
|
|
{
|
|
return $this->store->findLatestByUsername($username);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $input
|
|
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
|
*/
|
|
public function submit(array $input): array
|
|
{
|
|
$username = strtolower(trim((string) ($input['username'] ?? '')));
|
|
$givenName = trim((string) ($input['given_name'] ?? ''));
|
|
$familyName = trim((string) ($input['family_name'] ?? ''));
|
|
$email = strtolower(trim((string) ($input['email'] ?? '')));
|
|
$note = trim((string) ($input['note'] ?? ''));
|
|
$password = (string) ($input['password'] ?? '');
|
|
$passwordConfirm = (string) ($input['password_confirm'] ?? '');
|
|
$displayName = $givenName;
|
|
$fullName = trim($givenName . ' ' . $familyName);
|
|
$errors = [];
|
|
|
|
if (!preg_match('/^[a-z0-9._-]{3,32}$/', $username)) {
|
|
$errors[] = 'Der Benutzername muss 3 bis 32 Zeichen lang sein und darf nur Kleinbuchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.';
|
|
}
|
|
|
|
if ($givenName === '') {
|
|
$errors[] = 'Der Vorname ist erforderlich.';
|
|
}
|
|
|
|
if ($familyName === '') {
|
|
$errors[] = 'Der Nachname ist erforderlich.';
|
|
}
|
|
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
|
}
|
|
|
|
if (strlen($password) < 12) {
|
|
$errors[] = 'Das Passwort muss mindestens 12 Zeichen lang sein.';
|
|
}
|
|
|
|
if ($password !== $passwordConfirm) {
|
|
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
|
}
|
|
|
|
if (!$this->secretBox->isConfigured()) {
|
|
$errors[] = 'Die Registrierung ist im Moment nicht verfügbar.';
|
|
}
|
|
|
|
if ($note !== '' && mb_strlen($note) > 2000) {
|
|
$errors[] = 'Die Hinweise für die Freischaltung sind zu lang.';
|
|
}
|
|
|
|
$conflict = $this->store->findConflict($username, $email);
|
|
|
|
if ($conflict !== null) {
|
|
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail-Adresse existiert bereits eine Registrierung.';
|
|
}
|
|
|
|
if ($this->ldapProvisioner->usernameExists($username)) {
|
|
$errors[] = 'Dieser Benutzername ist leider bereits vergeben.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return [
|
|
'success' => false,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
$identities = $this->identityAllocator->allocate();
|
|
$pendingGroup = trim((string) ($this->config['ldap']['pending_group_dn'] ?? ''));
|
|
$provisioning = [
|
|
'uid_number' => $identities['uid_number'],
|
|
'gid_number' => $identities['gid_number'],
|
|
'home_directory' => rtrim((string) ($this->config['ldap']['home_directory_prefix'] ?? '/home'), '/') . '/' . $username,
|
|
'login_shell' => trim((string) ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')),
|
|
'member_of_list' => $pendingGroup,
|
|
'samba_sid' => $identities['samba_sid'],
|
|
'apple_generateduid' => '',
|
|
'display_name' => $displayName,
|
|
'cn' => $displayName !== '' ? $displayName : $username,
|
|
'gecos' => $fullName !== '' ? $fullName : $displayName,
|
|
'admin_note' => '',
|
|
'ldap_created_at' => gmdate('c'),
|
|
];
|
|
|
|
$request = [
|
|
'id' => bin2hex(random_bytes(8)),
|
|
'status' => 'pending_approval',
|
|
'created_at' => gmdate('c'),
|
|
'updated_at' => gmdate('c'),
|
|
'username' => $username,
|
|
'given_name' => $givenName,
|
|
'family_name' => $familyName,
|
|
'display_name' => $displayName,
|
|
'email' => $email,
|
|
'note' => $note,
|
|
'password_secret' => $this->secretBox->encrypt($password),
|
|
'origin' => [
|
|
'ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
|
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
|
'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''),
|
|
],
|
|
'review' => null,
|
|
'provisioning' => $provisioning,
|
|
];
|
|
$passwordSecret = $this->secretBox->encrypt($password);
|
|
|
|
try {
|
|
$ldapResult = $this->ldapProvisioner->createInactiveUser($request, $provisioning, $password);
|
|
$request['password_secret'] = null;
|
|
$request['provisioning']['ldap_dn'] = $ldapResult['dn'];
|
|
$request['provisioning']['group_updates'] = $ldapResult['group_updates'];
|
|
$request['provisioning']['status'] = 'ldap_created_inactive';
|
|
$request['provisioning']['ldif_path'] = $this->writeLdifDraft($request['id'], $username, $this->ldifBuilder->build($request, $provisioning));
|
|
} catch (\Throwable $exception) {
|
|
$errorReference = $this->logProvisioningFailure($request, $provisioning, $exception);
|
|
$request['password_secret'] = $passwordSecret;
|
|
$request['status'] = 'provisioning_failed';
|
|
$request['provisioning']['status'] = 'provisioning_failed';
|
|
$request['provisioning']['error'] = $exception->getMessage();
|
|
$request['provisioning']['error_reference'] = $errorReference;
|
|
|
|
return [
|
|
'success' => false,
|
|
'errors' => ['Die Registrierung konnte im Moment nicht gespeichert werden. Bitte versuche es später erneut. Referenz: ' . $errorReference],
|
|
'request' => $this->store->create($request),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'request' => $this->store->create($request),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $input
|
|
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
|
*/
|
|
public function approve(string $id, array $input, string $reviewer): array
|
|
{
|
|
$request = $this->store->find($id);
|
|
|
|
if ($request === null) {
|
|
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
|
|
}
|
|
|
|
$memberOfList = trim((string) ($input['member_of_list'] ?? ''));
|
|
$adminNote = trim((string) ($input['admin_note'] ?? ''));
|
|
$errors = [];
|
|
|
|
if ($memberOfList === '') {
|
|
$errors[] = 'Für die Freischaltung muss mindestens eine Zielgruppe ausgewählt werden.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['success' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$activate = $this->ldapProvisioner->activateUser((string) ($request['username'] ?? ''), preg_split('/\r\n|\r|\n/', $memberOfList) ?: []);
|
|
|
|
if (!($activate['success'] ?? false)) {
|
|
return ['success' => false, 'errors' => [(string) ($activate['message'] ?? 'Aktivierung fehlgeschlagen.')]];
|
|
}
|
|
|
|
$provisioning = [
|
|
'uid_number' => (string) ($request['provisioning']['uid_number'] ?? ''),
|
|
'gid_number' => (string) ($request['provisioning']['gid_number'] ?? ''),
|
|
'home_directory' => (string) ($request['provisioning']['home_directory'] ?? ''),
|
|
'login_shell' => (string) ($request['provisioning']['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')),
|
|
'member_of_list' => $memberOfList,
|
|
'samba_sid' => (string) ($request['provisioning']['samba_sid'] ?? ''),
|
|
'apple_generateduid' => (string) ($request['provisioning']['apple_generateduid'] ?? ''),
|
|
'display_name' => (string) ($request['provisioning']['display_name'] ?? $request['display_name'] ?? ''),
|
|
'cn' => (string) ($request['provisioning']['cn'] ?? $request['display_name'] ?? $request['username'] ?? ''),
|
|
'gecos' => (string) ($request['provisioning']['gecos'] ?? $request['display_name'] ?? ''),
|
|
'admin_note' => $adminNote,
|
|
'ldap_activated_at' => gmdate('c'),
|
|
'ldap_dn' => (string) ($activate['dn'] ?? ($request['provisioning']['ldap_dn'] ?? '')),
|
|
'status' => 'approved_active',
|
|
];
|
|
|
|
$request = $this->store->update(
|
|
$id,
|
|
static function (array $item) use ($provisioning, $reviewer, $adminNote): array {
|
|
$item['status'] = 'approved_active';
|
|
$item['updated_at'] = gmdate('c');
|
|
$item['review'] = [
|
|
'reviewed_at' => gmdate('c'),
|
|
'reviewed_by' => $reviewer,
|
|
'decision' => 'approved',
|
|
'note' => $adminNote,
|
|
];
|
|
$item['provisioning'] = $provisioning;
|
|
|
|
return $item;
|
|
}
|
|
);
|
|
|
|
if ($request === null) {
|
|
return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'request' => $request,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
|
*/
|
|
public function reject(string $id, string $reason, string $reviewer): array
|
|
{
|
|
$reason = trim($reason);
|
|
|
|
if ($reason === '') {
|
|
return ['success' => false, 'errors' => ['Bitte gib einen Ablehnungsgrund an.']];
|
|
}
|
|
|
|
$request = $this->store->update(
|
|
$id,
|
|
static function (array $item) use ($reason, $reviewer): array {
|
|
$item['status'] = 'rejected';
|
|
$item['updated_at'] = gmdate('c');
|
|
$item['review'] = [
|
|
'reviewed_at' => gmdate('c'),
|
|
'reviewed_by' => $reviewer,
|
|
'decision' => 'rejected',
|
|
'note' => $reason,
|
|
];
|
|
|
|
return $item;
|
|
}
|
|
);
|
|
|
|
if ($request === null) {
|
|
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'request' => $request,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message: string}
|
|
*/
|
|
public function sendPasswordReset(string $username, string $origin): array
|
|
{
|
|
return $this->passwordResetService->request($username, $origin);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message: string}
|
|
*/
|
|
public function deactivateUser(string $username): array
|
|
{
|
|
return $this->ldapProvisioner->deactivateUser($username);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message: string}
|
|
*/
|
|
public function activateExistingUser(string $username, array $groupDns): array
|
|
{
|
|
return $this->ldapProvisioner->activateUser($username, $groupDns);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message: string}
|
|
*/
|
|
public function setUserPassword(string $username, string $password): array
|
|
{
|
|
return $this->ldapProvisioner->updateUserPassword($username, $password);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function pendingApprovals(): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->all(),
|
|
static fn (array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending_approval', 'provisioning_failed'], true)
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, string>>
|
|
*/
|
|
public function availableGroups(): array
|
|
{
|
|
return array_values(array_filter(
|
|
array_map(
|
|
static fn (array $group): array => [
|
|
'dn' => (string) ($group['dn'] ?? ''),
|
|
'label' => (string) ($group['label'] ?? ($group['dn'] ?? '')),
|
|
],
|
|
(array) ($this->config['ldap']['available_groups'] ?? [])
|
|
),
|
|
static fn (array $group): bool => $group['dn'] !== ''
|
|
));
|
|
}
|
|
|
|
private function writeLdifDraft(string $id, string $username, string $content): string
|
|
{
|
|
$directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));
|
|
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0775, true);
|
|
}
|
|
|
|
$filename = $id . '-' . preg_replace('/[^a-z0-9._-]+/i', '-', $username) . '.ldif';
|
|
$path = rtrim($directory, '/') . '/' . $filename;
|
|
file_put_contents($path, $content, LOCK_EX);
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function resolveProjectPath(string $path): string
|
|
{
|
|
if ($path === '') {
|
|
return $this->projectRoot . '/data/registration-requests.json';
|
|
}
|
|
|
|
if (str_starts_with($path, '/')) {
|
|
return $path;
|
|
}
|
|
|
|
return $this->projectRoot . '/' . ltrim($path, '/');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $request
|
|
* @param array<string, mixed> $provisioning
|
|
*/
|
|
private function logProvisioningFailure(array $request, array $provisioning, \Throwable $exception): string
|
|
{
|
|
$reference = 'REG-' . gmdate('YmdHis') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
|
|
$path = $this->resolveProjectPath('data/registration-provisioning-errors.log');
|
|
$directory = dirname($path);
|
|
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0775, true);
|
|
}
|
|
|
|
$payload = [
|
|
'reference' => $reference,
|
|
'created_at' => gmdate('c'),
|
|
'message' => $exception->getMessage(),
|
|
'username' => (string) ($request['username'] ?? ''),
|
|
'email' => (string) ($request['email'] ?? ''),
|
|
'display_name' => (string) ($request['display_name'] ?? ''),
|
|
'origin' => $request['origin'] ?? [],
|
|
'provisioning' => $provisioning,
|
|
];
|
|
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL,
|
|
FILE_APPEND | LOCK_EX
|
|
);
|
|
|
|
return $reference;
|
|
}
|
|
}
|