diff --git a/.gitignore b/.gitignore index 3bdc4e06..4397360e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,17 @@ Vagrantfile # Staging directory used for packaging script stage + +# Ignore compiled phar files +*.phar + +# Ignore hydrated libs & include dir +**/lib +**/include + +# Ignore installed dependencies and composer if placed here +lib/* +!/lib/pear-pear.php.net/ +composer.phar +composer.lock +composer.json diff --git a/README.md b/README.md index fc2dc4a1..37151ebf 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,29 @@ Installing Clone this repo or download the zip file and place the contents into your `include/plugins` folder + +After cloning, `hydrate` the repo by downloading the third-party library +dependencies. + + php make.php hydrate + +Building Plugins +================ +Make any necessary additions or edits to plugins and build PHAR files with +the `make.php` command + + php -dphar.readonly=0 make.php build + +This will compile a PHAR file for the plugin directory. The PHAR will be +named `plugin.phar` and can be dropped into the osTicket `plugins/` folder +directly. + +Translating +=========== + +[![Crowdin](https://d322cqt584bo4o.cloudfront.net/osticket-plugins/localized.png)](http://i18n.osticket.com/project/osticket-plugins) + +Translation service is being performed via the Crowdin translation +management software. The project page for the plugins is located at + +https://crowdin.com/project/osticket-plugins diff --git a/audit/audit.php b/audit/audit.php new file mode 100644 index 00000000..ea61bf5b --- /dev/null +++ b/audit/audit.php @@ -0,0 +1,165 @@ +getConfig(); + if ($config->get('show_view_audits')) + AuditEntry::$show_view_audits = $config->get('show_view_audits'); + + // Ticket audit + Signal::connect('ticket.view.more', function($ticket, &$extras) { + global $thisstaff; + if (!$thisstaff || !$thisstaff->isAdmin()) + return; + + echo sprintf('
  • getId() . '/view'); + echo 'onclick="javascript: $.dialog($(this).attr(\'href\').substr(1), 201); return false;"'; + echo sprintf('>', 'icon-book' ?: 'icon-cogs'); + echo __('View Audit Log'); + echo '
  • '; + }); + + // User audit tab + Signal::connect('usertab.audit', function($user, &$extras) { + global $thisstaff; + if (!$thisstaff || !$thisstaff->isAdmin()) + return; + + $tabTitle = str_replace('-', ' ', __('audits')); + echo sprintf('
  • %s
  • ', __('audits'), __(ucwords($tabTitle))); + }); + + // User audit body + Signal::connect('user.audit', function($user, &$extras) { + global $thisstaff; + if (!$thisstaff || !$thisstaff->isAdmin()) + return; + + echo ''; + }); + + // Agent audit tab + Signal::connect('agenttab.audit', function($staff, &$extras) { + global $thisstaff; + if (!$thisstaff || !$thisstaff->isAdmin()) + return; + + echo '
  • Audits
  • '; + }); + + // Agent audit tab body + Signal::connect('agent.audit', function($staff, &$extras) { + global $thisstaff; + if (!$thisstaff || !$thisstaff->isAdmin()) + return; + + echo ''; + }); + + // Ajax View Ticket Audit + Signal::connect('ajax.scp', function($dispatcher) { + $dispatcher->append( + url_get('^/audit/ticket/(?P\d+)/view$', function($ticketId) { + global $thisstaff; + + $row = Ticket::objects()->filter(array('ticket_id'=>$ticketId))->values_flat('number')->first(); + if (!$row) + Http::response(404, 'No such ticket'); + if (!$thisstaff || !$thisstaff->isAdmin()) + Http::response(403, 'Contact your administrator'); + + include 'templates/ticket-audit.tmpl.php'; + }) + ); + }); + + // Ajax Audit Export + Signal::connect('ajax.scp', function($dispatcher) { + $dispatcher->append( + url('^/audit/export/(?P\w+)/(?P\w+)|uid,(?P\d+)|sid,(?P\d+)|tid,(?P\d+)$', + function($type=NULL, $state=NULL, $uid=NULL, $sid=NULL, $tid=NULL) { + global $thisstaff; + + if (!$thisstaff) + Http::response(403, 'Agent login is required'); + + $show = AuditEntry::$show_view_audits; + $data = array(); + if ($type) { + $url = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY); + $qarray = explode('&', $url); + + foreach ($qarray as $key => $value) { + list($k, $v) = explode('=', $value); + $data[$k] = $v; + } + foreach (AuditEntry::getTypes() as $abbrev => $info) { + if ($type == $abbrev) + $name = AuditEntry::getObjectName($info[0]); + } + $filename = sprintf('%s-audits-%s.csv', $name, strftime('%Y%m%d')); + $export = array('audit', $filename, '', '', 'csv', $show, $data); + } elseif ($uid) { + $userName = User::getNameById($uid); + $filename = sprintf('%s-audits-%s.csv', $userName->name, strftime('%Y%m%d')); + $export = array('user', $filename, $tableInfo, $uid, 'csv', $show, $data); + } elseif ($sid) { + $staff = Staff::lookup($sid); + $filename = sprintf('%s-audits-%s.csv', $staff->getName(), strftime('%Y%m%d')); + $export = array('staff', $filename, $tableInfo, $sid, 'csv', $show, $data); + } elseif ($tid) { + $ticket = Ticket::lookup($tid); + $filename = sprintf('%s-audits-%s.csv', $ticket->getNumber(), strftime('%Y%m%d')); + $export = array('ticket', $filename, $tableInfo, $tid, 'csv', $show, $data); + } + + try { + $interval = 5; + // Create desired exporter + $exporter = new CsvExporter(); + $extra = array('filename' => $filename, + 'interval' => $interval); + // Register the export in the session + Exporter::register($exporter, $extra); + // Flush response / return export id and check interval + Http::flush(201, json_encode(['eid' => + $exporter->getId(), 'interval' => $interval])); + // Phew... now we're free to do the export + session_write_close(); // Release session for other requests + ignore_user_abort(1); // Leave us alone bro! + @set_time_limit(0); // Useless when safe_mode is on + // Export to the exporter + $export[] = $exporter; + call_user_func_array(array('Export', 'audits'), $export); + $exporter->close(); + // Sleep 3 times the interval to allow time for file download + sleep($interval*3); + // Email the export if it exists + $exporter->email($thisstaff); + // Delete the file. + @$exporter->delete(); + exit; + } catch (Exception $ex) { + $errors['err'] = __('Unable to prepare the export'); + } + + include 'templates/export.tmpl.php'; + }) + ); + }); +} + + function enable() { + AuditEntry::autoCreateTable(); + return parent::enable(); + } +} diff --git a/audit/class.audit.php b/audit/class.audit.php new file mode 100644 index 00000000..505121fb --- /dev/null +++ b/audit/class.audit.php @@ -0,0 +1,856 @@ + AUDIT_TABLE, + 'pk' => array('id'), + 'ordering' => array('-timestamp'), + 'select_related' => array('staff', 'user'), + 'joins' => array( + 'staff' => array( + 'constraint' => array('staff_id' => 'Staff.staff_id'), + 'null' => true, + ), + 'user' => array( + 'constraint' => array('user_id' => 'User.id'), + 'null' => true, + ), + ), + ); + + //return an array with the object model, getName function, and url prefix + static $types = array( + 'S' => array('Staff', 'getName', 'staff.php'), + 'B' => array('Canned', 'getTitle', 'canned.php'), + 'C' => array('Category', 'getName', 'categories.php'), + 'X' => array('ConfigItem', 'none', 'none'), + 'D' => array('Dept', 'getName', 'departments.php'), + 'M' => array('Email', 'getName', 'emails.php'), + 'I' => array('EmailTemplateGroup', 'getName', 'templates.php'), + 'Q' => array('FAQ', 'getQuestion', 'faq.php'), + 'N' => array('DynamicForm', 'getTitle', 'forms.php'), + 'H' => array('Topic', 'getName', 'helptopics.php'), + 'L' => array('DynamicList', 'getName', 'lists.php'), + 'O' => array('Organization', 'getName', 'orgs.php'), + 'G' => array('Page', 'getName', 'pages.php'), + 'R' => array('Role', 'getName', 'roles.php'), + 'V' => array('SLA', 'getName', 'slas.php'), + 'A' => array('Task', 'getNumber', 'tasks.php'), + 'E' => array('Team', 'getName', 'teams.php'), + 'T' => array('Ticket', 'getNumber', 'tickets.php'), + 'F' => array('Filter', 'getName', 'filters.php'), + 'U' => array('User', 'getName', 'users.php'), + 'J' => array('ClientAccount', 'getUserName', 'users.php'), + ); + + static function bootstrap() { + Signal::connect('object.view', array('AuditEntry', 'auditObjectEvent')); + Signal::connect('object.created', array('AuditEntry', 'auditObjectEvent')); + Signal::connect('object.deleted', array('AuditEntry', 'auditObjectEvent')); + Signal::connect('object.edited', array('AuditEntry', 'auditObjectEvent')); + Signal::connect('person.login', array('AuditEntry', 'auditSpecialEvent')); + Signal::connect('person.logout', array('AuditEntry', 'auditSpecialEvent')); + } + + static function getObjectName($class) { + switch ($class) { + case 'Dept': + return __('Department'); + break; + case 'OrganizationModel': + return __('Organization'); + break; + case 'Canned': + return __('Canned Response'); + break; + case 'Topic': + return __('Help Topic'); + break; + case 'Staff': + return __('Agent'); + break; + case 'Filter': + return __('Ticket Filter'); + break; + case 'EmailTemplateGroup': + return __('Email Template'); + break; + case 'DynamicList': + return __('List'); + break; + case 'DynamicForm': + return __('Form'); + break; + case 'ConfigItem': + return __('Configuration'); + break; + case 'ClientAccount': + return __('User Account'); + break; + default: + return $class; + break; + } + } + + static $configurations = array( + 'time_format' => 'Time Format', //Configurations + 'date_format' => 'Date Format', + 'datetime_format' => 'Date and Time Format', + 'daydatetime_format' => 'Day Date and Time Format', + 'default_priority_id' => 'Default Priority', + 'reply_separator' => 'Reply Separator Tag', + 'isonline' => 'Helpdesk Status', + 'staff_ip_binding' => 'Bind Agent Session to IP', + 'staff_max_logins' => 'Staff Max Logins', + 'staff_login_timeout' => 'Staff Login TImeout', + 'staff_session_timeout' => 'Agent Session Timeout', + 'passwd_reset_period' => 'Password Expiration Policy', + 'client_max_logins' => 'User Max Logins', + 'client_login_timeout' => 'User Login TImeout', + 'client_session_timeout' => 'User Session Timeout', + 'max_page_size' => 'Default Page Size', + 'max_open_tickets' => 'Maximum Open Tickets', + 'autolock_minutes' => 'Collision Avoidance Duration', + 'default_priority_id' => 'Ticket Default Priority', + 'default_smtp_id' => 'Default MTA', + 'use_email_priority' => 'Emailed Tickets Priority', + 'enable_kb' => 'Enable Knowledge Base', + 'enable_premade' => 'Enable Canned Responses', + 'enable_captcha' => 'Human Verification:', + 'enable_auto_cron' => 'Fetch on auto-cron', + 'enable_mail_polling' => 'Email Fetching', + 'send_sys_errors' => 'System Errors', + 'send_sql_errors' => 'SQL Errors', + 'send_login_errors' => 'Excessive failed login attempts', + 'strip_quoted_reply' => 'Strip Quoted Reply', + 'ticket_autoresponder' => 'New Ticket Autoresponder', + 'message_autoresponder' => 'New Message Submitter Autoresponder', + 'ticket_notice_active' => 'New Ticket by Agent Autoresponder', + 'ticket_alert_active' => 'New Ticket Alert', + 'ticket_alert_admin' => 'Admin New Ticket Alert', + 'ticket_alert_dept_manager' => 'Manager New Ticket Alert', + 'ticket_alert_dept_members' => 'Dept Members New Ticket Alert', + 'message_alert_active' => 'New Message Alert', + 'message_alert_laststaff' => 'Last Respondent New Message Alert', + 'message_alert_assigned' => 'Assigned Agent / Team New Message Alert', + 'message_alert_dept_manager' => 'Department Manager New Message Alert', + 'note_alert_active' => 'New Internal Activity Alert', + 'note_alert_laststaff' => 'Last Respondent Internal Activity Alert', + 'note_alert_assigned' => 'Assigned Agent / Team Internal Activity Alert', + 'note_alert_dept_manager' => 'Department Manager Internal Activity Alert', + 'transfer_alert_active' => 'Ticket Transfer Alert', + 'transfer_alert_assigned' => 'Assigned Agent / Team Ticket Transfer Alert', + 'transfer_alert_dept_manager' => 'Department Manager Ticket Transfer Alert', + 'transfer_alert_dept_members' => 'Department Members Ticket Transfer Alert', + 'overdue_alert_active' => 'Overdue Ticket Alert', + 'overdue_alert_assigned' => 'Assigned Agent / Team Overdue Ticket Alert', + 'overdue_alert_dept_manager' => 'Department Manager Overdue Ticket Alert', + 'overdue_alert_dept_members' => 'Department Members Overdue Ticket Alert', + 'assigned_alert_active' => 'Ticket Assignment Alert', + 'assigned_alert_staff' => 'Assigned Agent Ticket Assignment Alert', + 'assigned_alert_team_lead' => 'Team Lead Ticket Assignment Alert', + 'assigned_alert_team_members' => 'Team Members Ticket Assignment Alert', + 'auto_claim_tickets' => 'Claim on Response', + 'collaborator_ticket_visibility' => 'Collaborator Tickets Visibility', + 'require_topic_to_close' => 'Require Help Topic to Close', + 'hide_staff_name' => 'Agent Identity Masking', + 'overlimit_notice_active' => 'Overlimit Notice Autoresponder', + 'email_attachments' => 'Email Attachments', + 'ticket_number_format' => 'Default Ticket Number Format', + 'ticket_sequence_id' => 'Default Ticket Number Sequence', + 'queue_bucket_counts' => 'Top-Level Ticket Counts', + 'task_number_format' => 'Default Task Number Format', + 'task_sequence_id' => 'Default Task Number Sequence', + 'log_level' => 'Default Log Level', + 'log_graceperiod' => 'Purge Logs', + 'client_registration' => 'Registration Method', + 'default_ticket_queue' => 'Default Ticket Queue', + 'accept_unregistered_email' => 'Accept All Emails', + 'add_email_collabs' => 'Accept Email Collaborators', + 'helpdesk_url' => 'Helpdesk URL', + 'helpdesk_title' => 'Helpdesk Name/Title', + 'default_dept_id' => 'Default Department', + 'enable_avatars' => 'Show Avatars', + 'enable_richtext' => 'Enable Rich Text', + 'default_locale' => 'Default Locale', + 'default_timezone' => 'Default Time Zone', + 'date_formats' => 'Date and Time Format', + 'system_language' => 'Primary Language', + 'add_secondary_language' => 'Secondary Languages', + 'default_storage_bk' => 'Store Attachments', + 'max_file_size' => 'Agent Maximum File Size', + 'files_req_auth' => 'Login required', + 'default_ticket_status_id' => 'Default Status', + 'default_sla_id' => 'Default SLA', + 'default_help_topic' => 'Default Help Topic', + 'ticket_lock' => 'Lock Semantics', + 'message_autoresponder_collabs' => 'New Message Participant Autoresponder', + 'ticket_alert_acct_manager' => 'Account Manager New Ticket Alert', + 'message_alert_acct_manager' => 'Account Manager New Message Alert', + 'default_task_priority_id' => 'Default Task Priority', + 'task_alert_active' => 'New Task Alert', + 'task_alert_admin' => 'New Task Admin Alert', + 'task_alert_dept_manager' => 'New Task Department Manager Alert', + 'task_alert_dept_members' => 'New Task Department Members Alert', + 'task_activity_alert_active' => 'New Task Activity Alert', + 'task_activity_alert_laststaff' => 'New Task Activity Last Respondent', + 'task_activity_alert_assigned' => 'New Task Activity Assigned Agent / Team', + 'task_activity_alert_dept_manager' => 'New Task Activity Department Manager', + 'task_assignment_alert_active' => 'Task Assignment Alert', + 'task_assignment_alert_staff' => 'Task Assignment Alert Assigned Agent / Team', + 'task_assignment_alert_team_lead' => 'Task Assignment Alert Team Lead', + 'task_assignment_alert_team_members' => 'Task Assignment Alert Team Members', + 'task_transfer_alert_active' => 'Task Transfer Alert', + 'task_transfer_alert_assigned' => 'Task Transfer Alert Assigned Agent / Team', + 'task_transfer_alert_dept_manager' => 'Task Transfer Alert Department Manager', + 'task_transfer_alert_dept_members' => 'Task Transfer Alert Department Members', + 'task_overdue_alert_active' => 'Overdue Task Alert', + 'task_overdue_alert_assigned' => 'Overdue Task Alert Assigned Agent / Team', + 'task_overdue_alert_dept_manager' => 'Overdue Task Alert Department Manager', + 'task_overdue_alert_dept_members' => 'Overdue Task Alert Department Members', + 'agent_name_format' => 'Agent Name Formatting', + 'agent_avatar' => 'Agent Avatar Source', + 'allow_pw_reset' => 'Allow Password Resets', + 'pw_reset_window' => 'Reset Token Expiration', + 'client_name_format' => 'User Name Formatting', + 'client_avatar' => 'User Avatar Source', + 'clients_only' => 'Registration Required', + 'allow_auth_tokens' => 'Authentication Token', + 'client_verify_email' => 'Client Quick Access', + 'restrict_kb' => 'Knowledgebase Require Client Login', + 'default_template_id' => 'Default Template Set', + 'default_email_id' => 'Default System Email', + 'alert_email_id' => 'Default Alert Email', + 'admin_email' => 'Admin Email Address', + 'verify_email_addrs' => 'Verify Email Addresses', + 'name' => 'Name', // Common Configurations + 'isactive' => 'Status', + 'notes' => 'Notes', + 'topic_id' => 'Help Topic', // Email Configurations + 'userid' => 'Username', + 'mail_active' => 'Mail Active', + 'mail_host' => 'Fetching Hostname', + 'mail_port' => 'Fetching Port', + 'mail_proto' => 'Mail Box Protocol', + 'mail_fetchfreq' => 'Fetch Frequency', + 'mail_fetchmax' => 'Emails Per Fetch', + 'postfetch' => 'Fetched Emails', + 'mail_archivefolder' => 'Mail Archive Folder', + 'smtp_active' => 'SMTP Active', + 'smtp_host' => 'SMTP Hostname', + 'smtp_port' => 'SMTP Port', + 'smtp_auth' => 'Authentication Required', + 'smtp_spoofing' => 'Header Spoofing', + 'mail_encryption' => 'Mail Encryption', + 'topic' => 'Name', // Help Topic Configurations + 'ispublic' => 'Type', + 'topic_pid' => 'Parent Topic', + 'dept_id' => 'Department', + 'custom-numbers' => 'Ticket Number Format', + 'number_format' => 'Number Format', + 'sequence_id' => 'Number Sequence', + 'priority_id' => 'Priority', + 'sla_id' => 'SLA Plan', + 'page_id' => 'Thank-You Page', + 'assign' => 'Auto-assign To', + 'noautoresp' => 'Auto-Response', + 'pid' => 'Parent', // Department Configurations + 'ispublic' => 'Type', + 'sla_id' => 'SLA', + 'manager_id' => 'Manager', + 'assignment_flag' => 'Ticket Assignment', + 'disable_auto_claim' => 'Claim on Response', + 'disable_reopen_auto_assign' => 'Reopen Auto Assignment', + 'email_id' => 'Outgoing Email', + 'tpl_id' => 'Template Set', + 'ticket_auto_response' => 'New Ticket', + 'message_auto_response' => 'New Message', + 'autoresp_email_id' => 'Auto-Response Email', + 'group_membership' => 'Recipients', + 'signature' => 'Department Signature', + 'grace_period' => 'Grace Period', // SLA Configurations + 'transient' => 'Transient', + 'disable_overdue_alerts' => 'Ticket Overdue Alerts', + 'type' => 'Type', // Page Configurations + 'body' => 'Page Content', + 'name_plural' => 'Plural Name', // List Configurations + 'sort_mode' => 'Sort Order', + 'ticket.activity.notice' => 'New Activity Notice', // Email Template Configurations + 'message.autoresp' => 'New Message Auto-response', + 'ticket.autoreply' => 'New Ticket Auto-reply', + 'ticket.autoresp' => 'New Ticket Auto-response', + 'ticket.notice' => 'New Ticket Notice', + 'ticket.overlimit' => 'Overlimit Notice', + 'note.alert' => 'Internal Activity Alert', + 'message.alert' => 'New Message Alert', + 'ticket.alert' => 'New Ticket Alert', + 'ticket.overdue' => 'Overdue Ticket Alert', + 'assigned.alert' => 'Ticket Assignment Alert', + 'transfer.alert' => 'Ticket Transfer Alert', + 'task.activity.alert' => 'New Activity Alert', + 'task.activity.notice' => 'New Activity Notice', + 'task.alert' => 'New Task Alert', + 'task.overdue.alert' => 'Overdue Task Alert', + 'task.assignment.alert' => 'Task Assignment Alert', + 'task.transfer.alert' => 'Task Transfer Alert', + 'firstname' => 'First Name', // Agent Configurations + 'lastname' => 'Last Name', + 'email' => 'Email', + 'phone' => 'Phone Number', + 'phone_ext' => 'Phone Extension', + 'mobile' => 'Mobile Number', + 'username' => 'Username', + 'default_from_name' => 'Default From Name', + 'thread_view_order' => 'Thread View Order', + 'default_ticket_queue_id' => 'Default Ticket Queue', + 'reply_redirect' => 'Reply Redirect', + 'islocked' => 'Locked', + 'isadmin' => 'Administrator', + 'assigned_only' => 'Limit Access to Assigned', + 'onvacation' => 'Vacation Mode', + 'dept_access' => 'Department Access', + 'role_id' => 'Role', + 'passwd' => 'Password', + 'backend' => 'Backend', + 'lang' => 'Language', + 'timezone' => 'Timezone', + 'locale' => 'Locale', + 'isvisible' => 'Visible', + 'show_assigned_tickets' => 'Show Assigned Tickets', + 'change_passwd' => 'Change Password', + 'auto_refresh_rate' => 'Auto Refresh Rate', + 'default_signature_type' => 'Default Signature Type', + 'default_paper_size' => 'Default Paper Size', + 'user.create' => 'Create Users', // Agent Permissions + 'user.delete' => 'Delete Users', + 'user.edit' => 'Edit Users', + 'user.manage' => 'Manage Users', + 'user.dir' => 'User Directory', + 'org.create' => 'Create Organizations', + 'org.delete' => 'Delete Organizations', + 'org.edit' => 'Edit Organizations', + 'faq.manage' => 'Manage FAQs', + 'emails.banlist' => 'Banlist', + 'search.all' => 'Search All', + 'stats.agents' => 'Stats', + 'isenabled' => 'Status', // Team Configurations + 'lead_id' => 'Team Lead', + 'noalerts' => 'Assignment Alert', + 'ticket.assign' => 'Ticket Assign', // Role Configurations + 'ticket.close' => 'Ticket Close', + 'ticket.create' => 'Ticket Create', + 'ticket.delete' => 'Ticket Delete', + 'ticket.edit' => 'Ticket Edit', + 'thread.edit' => 'Ticket Thread Edit', + 'ticket.reply' => 'Ticket Reply', + 'ticket.refer' => 'Ticket Refer', + 'ticket.release' => 'Ticket Release', + 'ticket.transfer' => 'Ticket Transfer', + 'task.assign' => 'Task Assign', + 'task.close' => 'Task Close', + 'task.create' => 'Task Create', + 'task.delete' => 'Task Delete', + 'task.edit' => 'Task Edit', + 'task.reply' => 'Task Reply', + 'task.transfer' => 'Task Transfer', + 'canned.manage' => 'Manage Canned Responses', + 'execorder' => 'Execution Order', // Ticket Filter Configurations + 'target' => 'Target Channel', + 'match_all_rules' => 'Match All Criteria', + 'stop_onmatch' => 'Stop On Match', + 'manager' => 'Account Manager', // Organization Configurations + 'assign-am-flag' => 'Auto-Assignment', + 'contacts' => 'Contacts', + 'sharing' => 'Ticket Sharing', + 'collab-pc-flag' => 'Auto Collaboration - Primary Contacts', + 'collab-all-flag' => 'Auto Collaboration - Organization Members', + 'domain' => 'Email Domain', + 'org' => 'Organization', // User Account Configurations + 'timezone' => 'Timezone', + 'password' => 'Password', + 'locked-flag' => 'Administratively Locked', + 'unlocked-flag' => 'Unlocked', + 'pwreset-flag' => 'Password Reset Required', + 'pwreset-sent' => 'Send Password Reset EMail', + 'user-registered' => 'Registered', + 'user-org' => 'Add to Organization', + 'forbid-pwchange-flag' => 'User cannot change password', + ); + + static $show_view_audits; + + function __toString() { + return (string) $this->id; + } + + //allows you to specify which part of the $types array you want returned + static function getTypeExtra($objectType, $infoType) { + foreach (self::getTypes() as $key => $info) { + if ($objectType == $key) { + switch ($infoType) { + case 'Model': + $extra = __($info[0]); + break; + case 'Name': + $extra = __($info[1]); + break; + case 'URL': + $extra = __($info[2]); + break; + } + } + } + return $extra; + } + + static function getPageNav($qwhere) { + $qselect = 'SELECT audit.* '; + $qfrom=' FROM '.AUDIT_TABLE.' audit '; + $total=db_count("SELECT count(*) $qfrom $qwhere"); + $page = ($_GET['p'] && is_numeric($_GET['p']))?$_GET['p']:1; + //pagenate + $pageNav=new Pagenate($total, $page, PAGE_LIMIT); + + return $pageNav; + } + + static function getQwhere($objectId, $hide_views=false, $type='') { + $class = is_object($objectId) ? get_class($objectId) : $objectId; + switch ($class) { + case 'User': + $qwhere = sprintf(' WHERE audit.user_id=%s', is_object($objectId) ? $objectId->getId() : $objectId); + break; + case 'Staff': + $qwhere = sprintf(' WHERE audit.staff_id=%s', is_object($objectId) ? $objectId->getId() : $objectId); + break; + case 'Ticket': + $qwhere = sprintf(' WHERE audit.object_id=%s', is_object($objectId) ? $objectId->getId() : $objectId); + break; + case 'AuditEntry': + $qwhere =' WHERE 1'; + $qwhere.=' AND object_type='.db_input($_REQUEST['type'] ?: 'D'); + if ($hide_views) + $qwhere.=' AND event_id='.db_input(Event::getIdByName($_REQUEST['state'])); + if ($_REQUEST['state'] && $_REQUEST['state'] != 'All') { + $event_id = Event::getIdByName(lcfirst($_REQUEST['state'])); + $qwhere.=' AND event_id='.db_input($event_id); + } + + //dates + $startTime =($_REQUEST['startDate'] && (strlen($_REQUEST['startDate'])>=8))?strtotime($_REQUEST['startDate']):0; + $endTime =($_REQUEST['endDate'] && (strlen($_REQUEST['endDate'])>=8))?strtotime($_REQUEST['endDate']):0; + if( ($startTime && $startTime>time()) or ($startTime>$endTime && $endTime>0)){ + $startTime=$endTime=0; + } else { + if($startTime) + $qwhere.=' AND timestamp>=FROM_UNIXTIME('.$startTime.')'; + if($endTime) + $qwhere.=' AND timestamp<=FROM_UNIXTIME('.$endTime.')'; + } + break; + default: + $qwhere = $type ? sprintf(' WHERE audit.object_id=%d AND audit.object_type = "%s"', $objectId, $type) : + sprintf('WHERE audit.object_id=%d', $objectId); + break; + + } + if (!self::$show_view_audits) + $qwhere.=' AND event_id !='.db_input(Event::getIdByName('viewed')); + + return $qwhere; + } + + static function getOrder($order) { + $or = null; + $orderWays=array('DESC'=>'DESC','ASC'=>'ASC'); + + if ($order && $orderWays[strtoupper($order)]) + $or = $orderWays[strtoupper($order)]; + elseif($_REQUEST['order'] && $orderWays[strtoupper($_REQUEST['order'])]) + $or = $orderWays[strtoupper($_REQUEST['order'])]; + + $or = $or ? $or : 'DESC'; + + return $or; + } + + static function getQuery($qs, $objectId, $pageNav, $export, $type='') { + $qselect = 'SELECT audit.* '; + $qfrom=' FROM '.AUDIT_TABLE.' audit '; + $qwhere =self::getQwhere($objectId, false, $type); + + $sortOptions=array('id'=>'audit.id', 'object_id'=>'audit.object_id', 'state'=>'audit.state','type'=>'audit.object_type','ip'=>'audit.ip' + ,'timestamp'=>'audit.timestamp'); + $sort=($_REQUEST['sort'] && $sortOptions[strtolower($_REQUEST['sort'])])?strtolower($_REQUEST['sort']):'timestamp'; + //Sorting options... + if($sort && $sortOptions[$sort]) { + $order_column =$sortOptions[$sort]; + } + $order_column=$order_column?$order_column:'timestamp'; + $order = self::getOrder($_REQUEST['order']); + + if($order_column && strpos($order_column,',')){ + $order_column=str_replace(','," $order,",$order_column); + } + $x=$sort.'_sort'; + $$x=' class="'.strtolower($order).'" '; + $order_by="$order_column $order "; + + $query = sprintf("$qselect $qfrom $qwhere %s", + $export ? '' : "ORDER BY $order_by LIMIT ".$pageNav->getStart().",".$pageNav->getLimit()); + + return $query; + } + + static function getTableInfo($objectId, $export=false, $type='') { + $qs = array(); + if($_REQUEST['type']) { + $qs += array('type' => $_REQUEST['type']); + } + + //pagenate + $qwhere =self::getQwhere($objectId, false, $type); + $pageNav=self::getPageNav($qwhere); + $query = self::getQuery($qs, $objectId, $pageNav, $export, $type); + $audits=db_query($query); + + if($audits && ($num=db_num_rows($audits))) + $showing=$pageNav->showing().' '.$title; + + $table = array(); + $count = 0; + foreach ($audits as $event) { + $class = is_object($objectId) ? get_class($objectId) : $objectId; + + $table[$count]['id'] = $event['id']; + $table[$count]['staff_id'] = $event['staff_id']; + $table[$count]['user_id'] = $event['user_id']; + $table[$count]['event_id'] = $event['event_id']; + $table[$count]['description'] = self::getDescription($event, $export); + $table[$count]['timestamp'] = Format::datetime($event['timestamp']); + $table[$count]['ip'] = $event['ip']; + $count++; + } + return $table; + } + + static function getTypes() { + return self::$types; + } + + static function getConfigurations() { + return self::$configurations; + } + + static function getDescription($event, $export=false, $userType='') { + $event = is_object($event) ? $event->ht : $event; + $data = json_decode($event['data'], true); + $name = ''; + if (!is_array($event)) + $event = $event->ht; + + if (!$person = $data['person']) + $person = $event['staff_id'] ? (Staff::lookup($event['staff_id'])) : (User::lookup($event['user_id'])); + + if ($person) + $name = is_string($person) ? $person : $person->getName()->name; + + if (!$userType) + $userType = $event['staff_id'] ? __('Agent') : __('User'); + + $model = AuditEntry::getTypeExtra($event['object_type'], 'Model'); + $objectName = AuditEntry::getObjectName($model); + $link = $event['object_type'] ? AuditEntry::getObjectLink($event) : ''; + $eventName = Event::getNameById($event['event_id']); + $description = sprintf(__('%s %s %s %s %s'), + $userType, $name, $eventName, $objectName, $link); + + switch ($eventName) { + case 'message': + $message = sprintf(__('%s %s posted a %s to %s %s'), + $userType, $name, $userType == 'Agent' ? 'reply' : 'message', $objectName, $link); + break; + case 'note': + $message = sprintf(__('%s %s posted a %s to %s %s'), + $userType, $name, $eventName, $objectName, $link); + break; + case 'collab': + $msg = $data['add'] ? 'Added ' : 'Deleted '; + $data = $data['add'] ?: $data['del']; + $name = array(); + $i = 0; + foreach ($data as $key => $value) { + if (is_numeric($key) && $i < 5) + $name[] = ($i < 4) ? $value['name'] : $value['name'] . '...'; + $i++; + } + $name = implode(',', $name); + $message = sprintf(__('%s %s %s Collaborator(s): %s Ticket: %s'), $userType, $person, $msg, $name, $link); + break; + case 'edited': + switch ($event['object_type']) { + case 'X': + foreach (self::getConfigurations() as $key => $value) { + if ($data['key'] == $key) + $configuration = __($value); + } + $message = sprintf(__('%s %s %s: %s'), $name ?: $userType, $data['type'] ?: 'Edited', $objectName, $configuration ?: $data['key']); + break; + case 'T': + case 'A': + if ($data['fields']) { + $fields = array(); + foreach ($data['fields'] as $key => $value) { + if (is_array($data['fields'][$key]) && $key == 'fields') + $key = key($data['fields'][$key]); + if (is_numeric($key)) { + $field = DynamicFormField::objects()->filter(array('id'=>$key))->values_flat('label')->first() ?: array(); + $fields[] = $field[0]; + } else { + $field[0] = ucfirst($key); + } + $message = sprintf(__('%s %s Edited Field(s): %s %s: %s '), $userType, + $name ?: $userType, + !empty($fields) ? implode(',',$fields) : ($field[0] ?: '-'), + $objectName, $link); + } + } + + break; + default: + if ($data['key']) { + foreach (self::getConfigurations() as $key => $value) { + if ($data['key'] == $key) + $configuration = __($value); + } + } + + $message = sprintf(__('%s %s %s %s %s'), $name ?: $userType, $data['status'] ?: 'Edited', $objectName, $link, $configuration ?: $data['key'] ?: ''); + break; + } + break; + case 'login': + case 'logout': + $message = sprintf(__('%s %s %s'),$userType, $name, $data['msg'] ?: Event::getNameById($event['event_id'])); + break; + case 'referred': + case 'transferred': + foreach ($data as $key => $value) { + $name = is_array($value) ? '' : $value; + if ($key != 'name') + $msg = sprintf(__('%s to %s %s'), $description, self::getObjectName(ucfirst($key)), $name); + } + $message = __($msg ?: $description); + break; + case 'assigned': + foreach ($data as $key => $value) { + $assignee = is_array($value) ? '' : $value; + + if ($key != 'name' && $value) + $msg = sprintf(__('%s to %s %s'), $description, self::getObjectName(ucfirst($key)), $assignee); + if ($key == 'claim') + $msg = sprintf(__('Agent %s Claimed %s'),$name ?: 'Agent', $link); + if ($key == 'auto') + $msg = sprintf(__('Agent SYSTEM Auto Assigned %s to %s'),$link, $name ?: 'Agent'); + } + $message = __($msg ?: Event::getNameById($event['event_id'])); + break; + default: + $message = __($description); + break; + } + return $export ? strip_tags($message) : $message; + } + + static function getDataById($id, $type) { + $row = self::objects() + ->filter(array('object_type'=>$type, 'object_id'=>$id)) + ->values_flat('object_type', 'object_id', 'data') + ->first(); + + return $row ? $row : 0; + } + + static function getObjectLink($event) { + $types = self::getTypes(); + $urlPrefix = self::getTypeExtra($event['object_type'], 'URL'); + $data = json_decode($event['data'], true); + $urlIdPrefix = $event['object_type'] == 'I' ? 'tpl_id' : 'id'; + + if ($event['event_id'] != 14) + $link = sprintf('%s', $urlPrefix, $urlIdPrefix, $event['object_id'], $data['name']); + else + $link = sprintf('%s', $data['name']); + + return $link; + } + + static function auditEvent($event_id, $object, $info) { + global $thisstaff, $thisclient; + + $event = static::create(); + + if (isset($info['data'])) + $event->data = $info['data']; + + //set the object_type based on the object's class + if (is_object($object)) { + foreach (self::getTypes() as $key => $info2) { + if (get_class($object) == $info2[0]) + $event->object_type = $key; + } + if ($event->object_type) + $event->object_id = $object->ht['id'] ?: $object->getId(); + else + return false; + } else { + $event->object_type = $object[0]; + $event->object_id = $object[1]; + $event->data = $object[2]; + } + + $event->event_id = $event_id; + $event->ip = osTicket::get_client_ip(); + + try { + if ($thisstaff) + $event->staff_id = $thisstaff->getId(); + elseif (is_object($object) && get_class($object) == 'Staff') + $event->staff_id = $object->getId(); + elseif (is_object($object) && get_class($object) == 'User') + $event->user_id = $object->getId(); + elseif ($info['uid']) + $event->user_id = $info['uid']; + elseif ($thisclient) + $event->user_id = $thisclient->getId(); + + return $event->save(); + } catch (Exception $e) { + //TODO: Return an error message + } + + } + + static function auditSpecialEvent($object, $info=array()) { + $data = array('person' => $object ? $object->getName()->name : '', + 'msg' => $info['msg'] ?: ''); + $info['data'] = json_encode($data); + $event_id = Event::getIdByName($info['type']); + return static::auditEvent($event_id, $object, $info); + } + + static function auditObjectEvent($object, $info=array()) { + global $thisstaff, $thisclient; + + $event_id = Event::getIdByName($info['type']); + $types = self::getTypes(); + foreach ($types as $abbrev => $data) { + if (is_object($object) && (get_class($object) == $data[0])) { + switch ($abbrev) { + case 'X': + $data = array('person' => $thisstaff ? $thisstaff->getName()->name : __('SYSTEM'), 'key' => $info['key']); + $info['data'] = json_encode($data); + break; + default: + $keys = array('updated', 'flags', 'mail_lastfetch', 'permissions', 'status'); + $classes = array('Email', 'Filter', 'Page', 'Role', 'Staff', 'Topic'); + if ($info['orm_audit'] && + (!in_array(get_class($object), $classes) || in_array($info['key'], $keys))) + return false; + + if (is_null($thisstaff) && is_null($thisclient) && + get_class($object) == 'Ticket' && $info['type'] != 'assigned') { + $person = $object->getUser()->getName()->name; + } elseif (is_null($thisstaff) && is_null($thisclient)) + $person = __('SYSTEM'); + + $name = $object ? call_user_func(array($object, $data[1])) : __('NA'); + $data = array('name' => is_object($name) ? $name->name : $name, + 'person' => $person ? $person : ($thisstaff ? $thisstaff->getName()->name : + $thisclient->getName()->name)); + foreach ($info as $key => $value) { + if ($key != 'type') + $data[$key] = $value; + } + + $info['data'] = json_encode($data); + break; + } + } + } + if (!is_object($object)) { + if (!is_array($object)) { + if ($data = AuditEntry::getDataById($object, $info['abbrev'])) + $name = json_decode($data[2], true); + else { + $name = __('NA'); + $data = array($info['abbrev'], $object); + } + $info['data'] = json_encode($data); + + return static::auditEvent($event_id, $data, $info); + } else + $info['data'] = json_encode($object); + } + + return static::auditEvent($event_id, $object, $info); + } + + static function create($vars=array()) { + $event = new static($vars); + $event->timestamp = SqlFunction::NOW(); + return $event; + } + + static function autoCreateTable() { + global $ost; + + $sql = 'SHOW TABLES LIKE \''.TABLE_PREFIX.'audit\''; + if (db_num_rows(db_query($sql))) + return true; + else { + $event_type = array('login', 'logout', 'message', 'note'); + foreach($event_type as $eType) { + $sql = sprintf("SELECT * FROM `%s` WHERE name = '%s'", + TABLE_PREFIX.'event', $eType); + + $res=db_query($sql); + $count = db_num_rows($res); + + if($count > 0) { + $message = "Event '$eType' already exists."; + $ost->logWarning('Audit Log Installation: Add Events', $message, false); + } else { + // Add event + $sql = sprintf("INSERT INTO `%s` (`id`, `name`, `description`) + VALUES + ('','%s',NULL)", + TABLE_PREFIX.'event', $eType); + + if(!($res=db_query($sql))) { + $message = "Unable to add $eType event to `".TABLE_PREFIX.'event'."`."; + $ost->logWarning('Audit Log Installation: Add Events', $message, false); + } + } + } + + $sql = sprintf('CREATE TABLE `%s` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `object_type` char(1) NOT NULL DEFAULT \'\', + `object_id` int(10) unsigned NOT NULL, + `event_id` int(11) unsigned DEFAULT NULL, + `staff_id` int(10) unsigned NOT NULL DEFAULT \'0\', + `user_id` int(10) unsigned NOT NULL DEFAULT \'0\', + `data` text, + `ip` varchar(64) DEFAULT NULL, + `timestamp` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `staff_id` (`staff_id`), + KEY `object_type` (`object_type`,`object_id`) + ) CHARSET=utf8', TABLE_PREFIX.'audit'); + return db_query($sql); + } + } +} diff --git a/audit/config.php b/audit/config.php new file mode 100644 index 00000000..87f104c1 --- /dev/null +++ b/audit/config.php @@ -0,0 +1,21 @@ + new BooleanField(array( + 'label' => __('Show View Audits'), + 'default' => true, + 'configuration' => array( + 'desc' => __('Show Audit Logs for when a User or Agent views Tickets') + ) + )), + ); + } + function pre_save(&$config, &$errors) { + global $msg; + if (!$errors) + $msg = __('Configuration updated successfully'); + return true; + } +} diff --git a/audit/plugin.php b/audit/plugin.php new file mode 100644 index 00000000..2c07cdd3 --- /dev/null +++ b/audit/plugin.php @@ -0,0 +1,13 @@ + 'audit:ticket', # notrans + 'version' => '0.1', + 'name' => 'Help Desk Audit', + 'author' => 'Adriane Alexander', + 'description' => 'Provides a configurable mechanism to audit viewing + and other activity of tickets.', + 'url' => 'http://www.osticket.com/download', + 'plugin' => 'audit.php:AuditPlugin', +); + +?> diff --git a/audit/templates/agent-audit.tmpl.php b/audit/templates/agent-audit.tmpl.php new file mode 100644 index 00000000..919f72af --- /dev/null +++ b/audit/templates/agent-audit.tmpl.php @@ -0,0 +1,68 @@ +getId()) { + $events = AuditEntry::getTableInfo($staff); + $total = count($events); + $qwhere = AuditEntry::getQwhere($staff); + $pageNav=AuditEntry::getPageNav($qwhere); + $pageNav->setURL('staff.php', $args); +} + + ?> +

    +
    + +
    " . $staff->getName() . " has performed.
    " +); ?> +
    + +
    + '.$pageNav->showing().''; + else + echo sprintf(__('%s does not have any audits'), __('Agent')); + ?> +
    + + + + + + + + + + + + + + + + + + + + + + +
    DescriptionTimestampIP Address
    + +
    +'; + if ($staffId) echo ' '.__('Page').':'.$pageNav->getPageLinks('audits').' '; + echo sprintf('%s', + $staffId, + 'audit-export', + __('Export')); + echo ''; +} +?> diff --git a/audit/templates/auditlogs.tmpl.php b/audit/templates/auditlogs.tmpl.php new file mode 100644 index 00000000..c1603bc0 --- /dev/null +++ b/audit/templates/auditlogs.tmpl.php @@ -0,0 +1,154 @@ +isAdmin()) die('Access Denied'); + +$qs = array(); +if($_REQUEST['type']) + $qs += array('type' => Format::htmlchars($_REQUEST['type'])); +$type='D'; + +if ($_REQUEST['type']) + $type=Format::htmlchars($_REQUEST['type']); + +if($_REQUEST['state']) + $qs += array('state' => Format::htmlchars($_REQUEST['state'])); +$state='All'; + +if ($_REQUEST['state']) + $state=Format::htmlchars($_REQUEST['state']); + +//dates +$startTime =($_REQUEST['startDate'] && (strlen($_REQUEST['startDate'])>=8))?strtotime($_REQUEST['startDate']):0; +$endTime =($_REQUEST['endDate'] && (strlen($_REQUEST['endDate'])>=8))?strtotime($_REQUEST['endDate']):0; +if( ($startTime && $startTime>time()) or ($startTime>$endTime && $endTime>0)){ + $errors['err']=__('Entered date span is invalid. Selection ignored.'); + $startTime=$endTime=0; +} else { + if($startTime) + $qs += array('startDate' => $_REQUEST['startDate']); + if($endTime) + $qs += array('endDate' => $_REQUEST['endDate']); +} +$order = AuditEntry::getOrder(Format::htmlchars($_REQUEST['order'])); +$qs += array('order' => (($order=='DESC') ? 'ASC' : 'DESC')); +$qstr = '&'. Http::build_query($qs); + +$args = array(); +parse_str($_SERVER['QUERY_STRING'], $args); +unset($args['p'], $args['_pjax']); + +// Apply pagination +$events = AuditEntry::getTableInfo('AuditEntry'); +$total = count($events); +$qwhere = AuditEntry::getQwhere('AuditEntry'); +$pageNav=AuditEntry::getPageNav($qwhere); +$pageNav->setURL('audits.php', $args); +?> + + +
    +
    +
    +
    +
    +
    +
    +

    + +

    +
    +
    +
    +
    + +
    + '.$pageNav->showing().''; + else + echo __('No audits found'); + ?> +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Description href="audits.php?&sort=timestamp">IP Address
    + +
    +'; +if ($total) { //Show options.. + echo ' '.__('Page').':'.$pageNav->getPageLinks().' '; +} +echo sprintf('%s', + $type, + $state, + 'audit-export', + __('Export')); +?> +
    diff --git a/audit/templates/ticket-audit.tmpl.php b/audit/templates/ticket-audit.tmpl.php new file mode 100644 index 00000000..5f1d30dd --- /dev/null +++ b/audit/templates/ticket-audit.tmpl.php @@ -0,0 +1,105 @@ +setURL('tickets.php', $args); +$order = AuditEntry::getOrder($_REQUEST['order']); +$qs = array(); +$qsReverse = array(); +$qs += array('order' => $order); +$qsReverse += array('order' => ($order=='DESC' ? 'ASC' : 'DESC')); +$qstr = Http::build_query($qs); +$qstrReverse = Http::build_query($qsReverse); +$url = '#audit/ticket/' . $ticketId . '/view?'; +$qstr = sprintf('%s&sort=timestamp', $qstr); + ?> +
    +

    + +
    +
    + '.$pageNav->showing().''; + else + echo sprintf(__('%s does not have any audits'), __('Ticket')); + ?> +
    +
    + +
    + + + + + + + + + + + + + + + + + +
    Description>IP Address
    +
    +getPageLinks('audits'); +$links = str_replace(''; + echo ' '.__('Page').':'.$links.' '; + echo sprintf('%s', + $ticketId, + 'audit-export', + __('Export')); + echo '
    '; +?> +

    + + + +

    + +
    + + + diff --git a/audit/templates/user-audit.tmpl.php b/audit/templates/user-audit.tmpl.php new file mode 100644 index 00000000..c46768fe --- /dev/null +++ b/audit/templates/user-audit.tmpl.php @@ -0,0 +1,57 @@ +setURL('users.php', $args); + ?> +

    +
    + +
    + '.$pageNav->showing().''; + else + echo sprintf(__('%s does not have any audits'), __('User')); + ?> +
    + + + + + + + + + + + + + + + + + + + +
    + +
    +'; + echo ' '.__('Page').':'.$pageNav->getPageLinks('audits').' '; + echo sprintf('%s', + $user->getId(), + 'audit-export', + __('Export')); + echo ''; +} +?> diff --git a/auth-2fa/auth2fa.php b/auth-2fa/auth2fa.php new file mode 100644 index 00000000..3d98d4cb --- /dev/null +++ b/auth-2fa/auth2fa.php @@ -0,0 +1,48 @@ +getConfig(); + if ($config->get('custom_issuer')) + Auth2FABackend::$custom_issuer = $config->get('custom_issuer'); + + TwoFactorAuthenticationBackend::register('Auth2FABackend'); + } + + function enable() { + return parent::enable(); + } + + function uninstall(&$errors) { + $errors = array(); + + self::disable(); + + return parent::uninstall($errors); + } + + function disable() { + $default2fas = ConfigItem::getConfigsByNamespace(false, 'default_2fa', Auth2FABackend::$id); + foreach($default2fas as $default2fa) + $default2fa->delete(); + + $tokens = ConfigItem::getConfigsByNamespace(false, Auth2FABackend::$id); + foreach($tokens as $token) + $token->delete(); + + return parent::disable(); + } +} + +require_once(INCLUDE_DIR.'UniversalClassLoader.php'); +use Symfony\Component\ClassLoader\UniversalClassLoader_osTicket; +$loader = new UniversalClassLoader_osTicket(); +$loader->registerNamespaceFallbacks(array( + dirname(__file__).'/lib')); +$loader->register(); diff --git a/auth-2fa/class.auth2fa.php b/auth-2fa/class.auth2fa.php new file mode 100644 index 00000000..713956eb --- /dev/null +++ b/auth-2fa/class.auth2fa.php @@ -0,0 +1,168 @@ +getQRCode($thisstaff); + if ($auth2FA->validateQRCode($thisstaff)) { + return array( + '' => new FreeTextField(array( + 'configuration' => array( + 'content' => sprintf( + ' + Use an Authenticator application on your phone to scan + the QR Code below. If you lose the QR Code + on the app, you will need to have your 2FA configurations reset by + a helpdesk Administrator. +
    + + + QR Code + + ', + $thisstaff->getEmail(), $qrCodeURL), + ) + )), + ); + } + } + + protected function getInputOptions() { + return array( + 'token' => new TextboxField(array( + 'id'=>1, 'label'=>__('Verification Code'), 'required'=>true, 'default'=>'', + 'validator'=>'number', + 'hint'=>__('Please enter the code from your Authenticator app'), + 'configuration'=>array( + 'size'=>40, 'length'=>40, + 'autocomplete' => 'one-time-code', + 'inputmode' => 'numeric', + 'pattern' => '[0-9]*', + 'validator-error' => __('Invalid Code format'), + ), + )), + ); + } + + function validate($form, $user) { + // Make sure form is valid and token exists + if (!($form->isValid() + && ($clean=$form->getClean()) + && $clean['token'])) + return false; + + if (!$this->validateLoginCode($clean['token'])) + return false; + + // upstream validation might throw an exception due to expired token + // or too many attempts (timeout). It's the responsibility of the + // caller to catch and handle such exceptions. + $secretKey = $this->getSecretKey(); + if (!$this->_validate($secretKey)) + return false; + + // Validator doesn't do house cleaning - it's our responsibility + $this->onValidate($user); + + return true; + } + + function send($user) { + global $cfg; + + // Get backend configuration for this user + if (!$cfg || !($info = $user->get2FAConfig($this->getId()))) + return false; + + // get configuration + $config = $info['config']; + + // Generate Secret Key + if (!$this->secretKey) + $this->secretKey = $this->getSecretKey($user); + + $this->store($this->secretKey); + + return true; + } + + function store($secretKey) { + global $thisstaff; + + $store = &$_SESSION['_2fa'][$this->getId()]; + $store = ['otp' => $secretKey, 'time' => time(), 'strikes' => 0]; + + if ($thisstaff) { + $config = array('config' => array('key' => $secretKey, 'external2fa' => true)); + $_config = new Config('staff.'.$thisstaff->getId()); + $_config->set($this->getId(), JsonDataEncoder::encode($config)); + $thisstaff->_config = $_config->getInfo(); + $errors['err'] = ''; + } + + return $store; + } + + function validateLoginCode($code) { + $auth2FA = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); + $secretKey = $this->getSecretKey(); + + return $auth2FA->checkCode($secretKey, $code); + } + + function getSecretKey($staff=false) { + if (!$staff) { + $s = StaffAuthenticationBackend::getUser(); + $staff = Staff::lookup($s->getId()); + } + + if (!$token = ConfigItem::getConfigsByNamespace('staff.'.$staff->getId(), static::$id)) { + $auth2FA = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); + $this->secretKey = $auth2FA->generateSecret(); + $this->store($this->secretKey); + } + + $key = $token->value ?: $this->secretKey; + if (strpos($key, 'config')) { + $key = json_decode($key, true); + $key = $key['config']['key']; + } + + return $key; + } + + function getQRCode($staff=false) { + $staffEmail = $staff->getEmail(); + $secretKey = $this->getSecretKey($staff); + $title = preg_replace('/[^A-Za-z0-9]/', '', self::$custom_issuer ?: __('osTicket')); + + return \Sonata\GoogleAuthenticator\GoogleQrUrl::generate($staffEmail, $secretKey, $title); + } + + function validateQRCode($staff=false) { + $auth2FA = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); + $secretKey = $this->getSecretKey($staff); + $code = self::getCode(); + + return $auth2FA->checkCode($secretKey, $code); + } + + static function getCode() { + $auth2FA = new \Sonata\GoogleAuthenticator\GoogleAuthenticator(); + $self = new Auth2FABackend(); + $secretKey = $self->getSecretKey(); + + return $auth2FA->getCode($secretKey); + } +} diff --git a/auth-2fa/config.php b/auth-2fa/config.php new file mode 100644 index 00000000..c1522550 --- /dev/null +++ b/auth-2fa/config.php @@ -0,0 +1,37 @@ + new TextboxField(array( + 'label' => __('Issuer'), + 'required' => false, + 'configuration' => array('size'=>40), + 'hint' => __('Customize the Issuer shown in your Authenticator app after scanning a QR Code.'), + )), + ); + } + + function pre_save(&$config, &$errors) { + global $msg; + if (!$errors) + $msg = __('Configuration updated successfully'); + return true; + } +} diff --git a/auth-2fa/plugin.php b/auth-2fa/plugin.php new file mode 100644 index 00000000..fa82b3ae --- /dev/null +++ b/auth-2fa/plugin.php @@ -0,0 +1,21 @@ + '2fa:auth', # notrans + 'version' => '0.3', + 'name' => /* trans */ 'Two Factor Authenticator', + 'author' => 'Adriane Alexander', + 'description' => /* trans */ 'Provides 2 Factor Authentication + using an Authenticator App', + 'url' => 'https://www.osticket.com/download', + 'plugin' => 'auth2fa.php:Auth2FAPlugin', + 'requires' => array( + "sonata-project/google-authenticator" => array( + "version" => "*", + "map" => array( + "sonata-project/google-authenticator/src" => 'lib/Sonata/GoogleAuthenticator', + ) + ), + ), +); +?> diff --git a/auth-cas/authentication.php b/auth-cas/authentication.php new file mode 100644 index 00000000..fa8e76ac --- /dev/null +++ b/auth-cas/authentication.php @@ -0,0 +1,31 @@ +getConfig(); + + $enabled = $config->get('cas-enabled'); + if (in_array($enabled, array('all', 'staff'))) { + require_once('cas.php'); + StaffAuthenticationBackend::register( + new CasStaffAuthBackend($this->getConfig())); + } + if (in_array($enabled, array('all', 'client'))) { + require_once('cas.php'); + UserAuthenticationBackend::register( + new CasClientAuthBackend($this->getConfig())); + } + } +} + +require_once(INCLUDE_DIR.'UniversalClassLoader.php'); +use Symfony\Component\ClassLoader\UniversalClassLoader_osTicket; +$loader = new UniversalClassLoader_osTicket(); +$loader->registerNamespaceFallbacks(array( + dirname(__file__).'/lib')); +$loader->register(); diff --git a/auth-cas/cas.php b/auth-cas/cas.php new file mode 100644 index 00000000..774f757c --- /dev/null +++ b/auth-cas/cas.php @@ -0,0 +1,174 @@ +config = $config; + } + + function triggerAuth() { + $self = $this; + phpCAS::client( + CAS_VERSION_2_0, + $this->config->get('cas-hostname'), + intval($this->config->get('cas-port')), + $this->config->get('cas-context') + ); + if($this->config->get('cas-ca-cert-path')) { + phpCAS::setCasServerCACert($this->config->get('cas-ca-cert-path')); + } else { + phpCAS::setNoCasServerValidation(); + } + if(!phpCAS::isAuthenticated()) { + phpCAS::forceAuthentication(); + } else { + $this->setUser(); + $this->setEmail(); + $this->setName(); + } + } + + function setUser() { + $_SESSION[':cas']['user'] = phpCAS::getUser(); + } + + function getUser() { + return $_SESSION[':cas']['user']; + } + + function setEmail() { + if($this->config->get('cas-email-attribute-key') !== null + && phpCAS::hasAttribute($this->config->get('cas-email-attribute-key'))) { + $_SESSION[':cas']['email'] = phpCAS::getAttribute( + $this->config->get('cas-email-attribute-key')); + } else { + $email = $this->getUser(); + if($this->config->get('cas-at-domain') !== null) { + $email .= $this->config->get('cas-at-domain'); + } + $_SESSION[':cas']['email'] = $email; + } + } + + function getEmail() { + return $_SESSION[':cas']['email']; + } + + function setName() { + if($this->config->get('cas-name-attribute-key') !== null + && phpCAS::hasAttribute($this->config->get('cas-name-attribute-key'))) { + $_SESSION[':cas']['name'] = phpCAS::getAttribute( + $this->config->get('cas-name-attribute-key')); + } else { + $_SESSION[':cas']['name'] = $this->getUser(); + } + } + + function getName() { + return $_SESSION[':cas']['name']; + } + + function getProfile() { + return array( + 'email' => $this->getEmail(), + 'name' => $this->getName() + ); + } +} + +class CasStaffAuthBackend extends ExternalStaffAuthenticationBackend { + static $id = "cas"; + static $name = /* trans */ "CAS"; + + static $service_name = "CAS"; + + var $config; + + function __construct($config) { + $this->config = $config; + $this->cas = new CasAuth($config); + } + + function getName() { + $config = $this->config; + list($__, $_N) = $config::translate(); + return $__(static::$name); + } + + function signOn() { + if (isset($_SESSION[':cas']['user'])) { + $staff = new StaffSession($this->cas->getEmail()); + if ($staff && $staff->getId()) { + return $staff; + } else { + $_SESSION['_staff']['auth']['msg'] = 'Have your administrator create a local account'; + } + } + } + + static function signOut($user) { + parent::signOut($user); + unset($_SESSION[':cas']); + } + + + function triggerAuth() { + parent::triggerAuth(); + $cas = $this->cas->triggerAuth(); + Http::redirect(ROOT_PATH . 'scp/'); + } +} + +class CasClientAuthBackend extends ExternalUserAuthenticationBackend { + static $id = "cas.client"; + static $name = /* trans */ "CAS"; + + static $service_name = "CAS"; + + function __construct($config) { + $this->config = $config; + $this->cas = new CasAuth($config); + } + + function getName() { + $config = $this->config; + list($__, $_N) = $config::translate(); + return $__(static::$name); + } + + function supportsInteractiveAuthentication() { + return false; + } + + function signOn() { + if (isset($_SESSION[':cas']['user'])) { + $acct = ClientAccount::lookupByUsername($this->cas->getEmail()); + $client = null; + if ($acct && $acct->getId()) { + $client = new ClientSession(new EndUser($acct->getUser())); + } + + if ($client) { + return $client; + } else { + return new ClientCreateRequest( + $this, $this->cas->getEmail(), $this->cas->getProfile()); + } + } + } + + static function signOut($user) { + parent::signOut($user); + unset($_SESSION[':cas']); + } + + function triggerAuth() { + parent::triggerAuth(); + $cas = $this->cas->triggerAuth(); + Http::redirect(ROOT_PATH . 'login.php'); + } +} diff --git a/auth-cas/config.php b/auth-cas/config.php new file mode 100644 index 00000000..ec625455 --- /dev/null +++ b/auth-cas/config.php @@ -0,0 +1,69 @@ + $__('Authentication'), + 'default' => "disabled", + 'choices' => array( + 'disabled' => $__('Disabled'), + 'staff' => $__('Agents Only'), + 'client' => $__('Clients Only'), + 'all' => $__('Agents and Clients'), + ), + )); + return array( + 'cas' => new SectionBreakField(array( + 'label' => $__('CAS Authentication'), + )), + 'cas-hostname' => new TextboxField(array( + 'label' => $__('CAS Server Hostname'), + 'configuration' => array('size'=>60, 'length'=>100), + )), + 'cas-port' => new TextboxField(array( + 'label' => $__('CAS Server Port'), + 'configuration' => array('size'=>10, 'length'=>8), + )), + 'cas-context' => new TextboxField(array( + 'label' => $__('CAS Server Context'), + 'configuration' => array('size'=>60, 'length'=>100), + 'hint' => $__('This value is "/cas" for most installs.'), + )), + 'cas-ca-cert-path' => new TextboxField(array( + 'label' => $__('CAS CA Cert Path'), + 'configuration' => array('size'=>60, 'length'=>100), + )), + 'cas-at-domain' => new TextboxField(array( + 'label' => $__('CAS e-mail suffix'), + 'configuration' => array('size'=>60, 'length'=>100), + 'hint' => $__('Use this field if your CAS server does not + report an e-mail attribute. ex: "@domain.tld"'), + )), + 'cas-name-attribute-key' => new TextboxField(array( + 'label' => $__('CAS name attribute key'), + 'configuration' => array('size'=>60, 'length'=>100), + )), + 'cas-email-attribute-key' => new TextboxField(array( + 'label' => $__('CAS email attribute key'), + 'configuration' => array('size'=>60, 'length'=>100), + )), + 'cas-enabled' => clone $modes, + ); + } +} diff --git a/auth-cas/plugin.php b/auth-cas/plugin.php new file mode 100644 index 00000000..a3294d1a --- /dev/null +++ b/auth-cas/plugin.php @@ -0,0 +1,21 @@ + 'auth:cas', # notrans + 'version' => '0.2', + 'name' => /* trans */ 'JASIG CAS Authentication', + 'author' => 'Kevin O\'Connor', + 'description' => /* trans */ 'Provides a configurable authentication + backend for authenticating staff and clients using CAS.', + 'url' => 'http://www.osticket.com/plugins/auth/cas', + 'plugin' => 'authentication.php:CasAuthPlugin', + 'requires' => array( + "jasig/phpcas" => array( + "version" => "1.3.3", + "map" => array( + "jasig/phpcas/source" => 'lib/jasig/phpcas', + ) + ), + ), +); + +?> diff --git a/auth-ldap/authentication.php b/auth-ldap/authentication.php index e8b45fd4..2f93de8d 100644 --- a/auth-ldap/authentication.php +++ b/auth-ldap/authentication.php @@ -16,30 +16,30 @@ function splat($what) { } require_once(INCLUDE_DIR.'class.auth.php'); -class LDAPAuthentication extends AuthenticationBackend { - static $name = "Active Directory or LDAP"; - static $id = "ldap"; +class LDAPAuthentication { /** * LDAP typical schema variations * * References: * http://www.kouti.com/tables/userattributes.htm (AD) + * https://fsuid.fsu.edu/admin/lib/WinADLDAPAttributes.html (AD) */ static $schemas = array( 'msad' => array( 'user' => array( 'filter' => '(objectClass=user)', - 'base' => 'CN=users', - 'first' => 'firstName', - 'last' => 'lastName', + 'base' => 'CN=Users', + 'first' => 'givenName', + 'last' => 'sn', 'full' => 'displayName', 'email' => 'mail', - 'phone' => false, + 'phone' => 'telephoneNumber', 'mobile' => false, 'username' => 'sAMAccountName', 'dn' => '{username}@{domain}', - 'search' => '(&(objectCategory=person)(objectClass=user)(sAMAccountName={q}*))', + 'search' => '(&(objectCategory=person)(objectClass=user)(|(sAMAccountName={q}*)(firstName={q}*)(lastName={q}*)(displayName={q}*)))', + 'lookup' => '(&(objectCategory=person)(objectClass=user)({attr}={q}))', ), 'group' => array( 'ismember' => '(&(objectClass=user)(sAMAccountName={username}) @@ -50,7 +50,7 @@ class LDAPAuthentication extends AuthenticationBackend { // A general approach for RFC-2307 '2307' => array( 'user' => array( - 'filter' => '(objectClass=posixAccount)', + 'filter' => '(objectClass=inetOrgPerson)', 'first' => 'gn', 'last' => 'sn', 'full' => array('displayName', 'gecos', 'cn'), @@ -59,21 +59,24 @@ class LDAPAuthentication extends AuthenticationBackend { 'mobile' => 'mobileTelephoneNumber', 'username' => 'uid', 'dn' => 'uid={username},{search_base}', - 'search' => '(&(objectClass=posixAccount)(uid={q}*))', + 'search' => '(&(objectClass=inetOrgPerson)(|(uid={q}*)(displayName={q}*)(cn={q}*)))', + 'lookup' => '(&(objectClass=inetOrgPerson)({attr}={q}))', ), ), ); var $config; + var $type = 'staff'; - function __construct($config) { + function __construct($config, $type='staff') { $this->config = $config; + $this->type = $type; } function getConfig() { return $this->config; } - function autodiscover($domain, $dns=array()) { + static function autodiscover($domain, $dns=array()) { require_once(PEAR_DIR.'Net/DNS2.php'); // TODO: Lookup DNS server from hosts file if not set $q = new Net_DNS2_Resolver(); @@ -108,18 +111,26 @@ function getServers() { || !($servers = preg_split('/\s+/', $servers))) { if ($domain = $this->getConfig()->get('domain')) { $dns = preg_split('/,?\s+/', $this->getConfig()->get('dns')); - return $this->autodiscover($domain, $dns); + return self::autodiscover($domain, array_filter($dns)); } } if ($servers) { $hosts = array(); foreach ($servers as $h) - $hosts[] = array('host'=>$h); + if (preg_match('/((ldaps?:\/\/)?([^:]+)):(\d{1,4})/', $h, $matches)) + $hosts[] = array('host' => $matches[1], 'port' => (int) $matches[4]); + else + $hosts[] = array('host' => $h); return $hosts; } } - function getConnection() { + function getConnection($force_reconnect=false) { + static $connection = null; + + if ($connection && !$force_reconnect) + return $connection; + require_once('include/Net/LDAP2.php'); // Set reasonable timeout limits $defaults = array( @@ -145,9 +156,28 @@ function getConnection() { $params = $defaults + $s; $c = new Net_LDAP2($params); $r = $c->bind(); - if (!PEAR::isError($r)) + if (!PEAR::isError($r)) { + $connection = $c; return $c; - var_dump($r); + } + } + } + + /** + * Binds to the directory under the search-user credentials configured + */ + function _bind($connection) { + if ($dn = $this->getConfig()->get('bind_dn')) { + $pw = Crypto::decrypt($this->getConfig()->get('bind_pw'), + SECRET_SALT, $this->getConfig()->getNamespace()); + $r = $connection->bind($dn, $pw); + unset($pw); + return !PEAR::isError($r); + } + else { + // try anonymous bind + $r = $connection->bind(); + return !PEAR::isError($r); } } @@ -179,6 +209,11 @@ function($match) use ($username, $domain, $config) { return $username; case 'domain': return $domain; + case 'search_base': + if (!$config->get('search_base')) + return 'dc=' . implode(',dc=', + explode('.', $config->get('domain'))); + // Fall through to default default: return $config->get($match[1]); } @@ -187,13 +222,34 @@ function($match) use ($username, $domain, $config) { ); $r = $c->bind($dn, $password); if (!PEAR::isError($r)) - return $this->lookupAndSync($username); - } + return $this->lookupAndSync($username, $dn); - function lookupAndSync($username) { - if (($user = new StaffSession($username)) && $user->getId()) - return $user; - // TODO: Auto-create users, etc. + // Another effort is to search for the user + if (!$this->_bind($c)) + return null; + + $r = $c->search( + $this->getSearchBase(), + str_replace( + array('{attr}','{q}'), + // Assume email address if the $username contains an @ sign + array(strpos($username, '@') ? $schema['email'] : $schema['username'], + $username), + $schema['lookup']), + array('sizelimit' => 1) + ); + if (PEAR::isError($r) || !$r->count() || !$r->current()) + return null; + + // Attempt to bind as the DN of the user looked up with the password + // specified + $bound = $c->bind($r->current()->dn(), $password); + if (PEAR::isError($bound)) + return null; + + // TODO: Save the DN in the config table so a lookup isn't necessary + // in the future + return $this->lookupAndSync($username, $r->current()->dn()); } /** @@ -203,7 +259,7 @@ function lookupAndSync($username) { function getSchema($connection) { $schema = $this->getConfig()->get('schema'); if (!$schema || $schema == 'auto') { - $dse = $connection->rootDse(); + $dse = $connection->rootDse(array('supportedCapabilities')); // Microsoft Active Directory // http://www.alvestrand.no/objectid/1.2.840.113556.1.4.800.html if (($caps = $dse->getValue('supportedCapabilities')) @@ -219,21 +275,36 @@ function getSchema($connection) { return '2307'; } - function supportsLookup() { return true; } + function lookup($lookup_dn, $bind=true) { + $c = $this->getConnection(); + if ($bind && !$this->_bind($c)) + return null; + + $schema = static::$schemas[$this->getSchema($c)]; + $schema = $schema['user']; + $opts = array( + 'scope' => 'base', + 'sizelimit' => 1, + 'attributes' => array_filter(flatten(array( + $schema['first'], $schema['last'], $schema['full'], + $schema['phone'], $schema['mobile'], $schema['email'], + $schema['username'], + ))) + ); + $r = $c->search($lookup_dn, '(objectClass=*)', $opts); + if (PEAR::isError($r) || !$r->count()) + return null; + + return $this->_getUserInfoArray($r->current(), $schema); + } - function supportsSearch() { return true; } function search($query) { $c = $this->getConnection(); // TODO: Include bind information $users = array(); - if ($dn = $this->getConfig()->get('bind_dn')) { - $pw = Crypto::decrypt($this->getConfig()->get('bind_pw'), - SECRET_SALT, $this->getConfig()->getNamespace()); - $r = $c->bind($dn, $pw); - unset($pw); - if (PEAR::isError($r)) - return $users; - } + if (!$this->_bind($c)) + return $users; + $schema = static::$schemas[$this->getSchema($c)]; $schema = $schema['user']; $r = $c->search( @@ -242,31 +313,15 @@ function search($query) { array('attributes' => array_filter(flatten(array( $schema['first'], $schema['last'], $schema['full'], $schema['phone'], $schema['mobile'], $schema['email'], - $schema['username'] + $schema['username'], 'dn', )))) ); // XXX: Log or return some kind of error? if (PEAR::isError($r)) return $users; - foreach ($r as $e) { - // Detect first and last name if only full name is given - if (!($first = $e->getValue($schema['first'])) - || !($last = $e->getValue($schema['last']))) { - $name = new PersonsName($this->_getValue($e, $schema['full'])); - $first = $name->getFirst(); - $last = $name->getLast(); - } - $users[] = array( - 'username' => $this->_getValue($e, $schema['username']), - 'first' => $first, - 'last' => $last, - 'email' => $this->_getValue($e, $schema['email']), - 'phone' => $this->_getValue($e, $schema['phone']), - 'mobile' => $this->_getValue($e, $schema['mobile']), - 'backend' => static::$id, - ); - } + foreach ($r as $e) + $users[] = $this->_getUserInfoArray($e, $schema); return $users; } @@ -278,14 +333,154 @@ function getSearchBase() { } function _getValue($entry, $names) { - foreach (splat($names) as $n) + foreach (array_filter(splat($names)) as $n) // Support multi-value attributes - foreach (splat($entry->getValue($n)) as $val) + foreach (splat($entry->getValue($n, 'all')) as $val) // Return the first non-bool-false value of the entries if ($val) return $val; } + function _getUserInfoArray($e, $schema) { + // Detect first and last name if only full name is given + if (!($first = $this->_getValue($e, $schema['first'])) + || !($last = $this->_getValue($e, $schema['last']))) { + $name = new PersonsName($this->_getValue($e, $schema['full'])); + $first = $name->getFirst(); + $last = $name->getLast(); + } + else + $name = "$first $last"; + + return array( + 'username' => $this->_getValue($e, $schema['username']), + 'first' => $first, + 'last' => $last, + 'name' => $name, + 'email' => $this->_getValue($e, $schema['email']), + 'phone' => $this->_getValue($e, $schema['phone']), + 'mobile' => $this->_getValue($e, $schema['mobile']), + 'dn' => $e->dn(), + ); + } + + function lookupAndSync($username, $dn) { + switch ($this->type) { + case 'staff': + if (($user = StaffSession::lookup($username)) && $user->getId()) { + if (!$user instanceof StaffSession) { + // osTicket <= v1.9.7 or so + $user = new StaffSession($user->getId()); + } + return $user; + } + break; + case 'client': + $c = $this->getConnection(); + if ('msad' == $this->getSchema($c) && stripos($dn, ',dc=') === false) { + // The user login DN will be user@domain. We need an LDAP DN + // -- fetch the real DN which looks like `CN=blah,DC=` + // NOTE: Already bound, so no need to bind again + list($samid) = explode('@', $dn); + $r = $c->search( + $this->getSearchBase(), + sprintf('(|(userPrincipalName=%s)(samAccountName=%s))', $dn, $samid), + $opts); + if (!PEAR::isError($r) && $r->count() && $r->current()) + $dn = $r->current()->dn(); + } + + // Lookup all the information on the user. Try to get the email + // addresss as well as the username when looking up the user + // locally. + if (!($info = $this->lookup($dn, false))) + return; + + $acct = false; + foreach (array($username, $info['username'], $info['email']) as $name) { + if ($name && ($acct = ClientAccount::lookupByUsername($name))) + break; + } + if (!$acct) + return new ClientCreateRequest($this, $username, $info); + + if (($client = new ClientSession(new EndUser($acct->getUser()))) + && !$client->getId()) + return; + + return $client; + } + + // TODO: Auto-create users, etc. + } +} + +class StaffLDAPAuthentication extends StaffAuthenticationBackend + implements AuthDirectorySearch { + + static $name = /* trans */ "Active Directory or LDAP"; + static $id = "ldap"; + + function __construct($config) { + $this->_ldap = new LDAPAuthentication($config); + $this->config = $config; + } + + function authenticate($username, $password=false, $errors=array()) { + return $this->_ldap->authenticate($username, $password); + } + + function getName() { + $config = $this->config; + list($__, $_N) = $config->translate(); + return $__(static::$name); + } + + function lookup($dn) { + $hit = $this->_ldap->lookup($dn); + if ($hit) { + $hit['backend'] = static::$id; + $hit['id'] = $this->getBkId() . ':' . $hit['dn']; + } + return $hit; + } + + function search($query) { + if (strlen($query) < 3) + return array(); + + $hits = $this->_ldap->search($query); + foreach ($hits as &$h) { + $h['backend'] = static::$id; + $h['id'] = $this->getBkId() . ':' . $h['dn']; + } + return $hits; + } +} + +class ClientLDAPAuthentication extends UserAuthenticationBackend { + static $name = /* trans */ "Active Directory or LDAP"; + static $id = "ldap.client"; + + function __construct($config) { + $this->_ldap = new LDAPAuthentication($config, 'client'); + $this->config = $config; + if ($domain = $config->get('domain')) + self::$name .= sprintf(' (%s)', $domain); + } + + function getName() { + $config = $this->config; + list($__, $_N) = $config->translate(); + return $__(static::$name); + } + + function authenticate($username, $password=false, $errors=array()) { + $object = $this->_ldap->authenticate($username, $password); + if ($object instanceof ClientCreateRequest) + $object->setBackend($this); + return $object; + } } require_once(INCLUDE_DIR.'class.plugin.php'); @@ -294,8 +489,10 @@ class LdapAuthPlugin extends Plugin { var $config_class = 'LdapConfig'; function bootstrap() { - AuthenticationBackend::register(new LDAPAuthentication($this->getConfig())); + $config = $this->getConfig(); + if ($config->get('auth-staff')) + StaffAuthenticationBackend::register(new StaffLDAPAuthentication($config)); + if ($config->get('auth-client')) + UserAuthenticationBackend::register(new ClientLDAPAuthentication($config)); } } - -?> diff --git a/auth-ldap/config.php b/auth-ldap/config.php index 80a18141..223c9d04 100644 --- a/auth-ldap/config.php +++ b/auth-ldap/config.php @@ -3,88 +3,142 @@ require_once(INCLUDE_DIR.'/class.plugin.php'); require_once(INCLUDE_DIR.'/class.forms.php'); + class LdapConfig extends PluginConfig { + + // Provide compatibility function for versions of osTicket prior to + // translation support (v1.9.4) + function translate() { + if (!method_exists('Plugin', 'translate')) { + return array( + function($x) { return $x; }, + function($x, $y, $n) { return $n != 1 ? $y : $x; }, + ); + } + return Plugin::translate('auth-ldap'); + } + function getOptions() { + list($__, $_N) = self::translate(); return array( 'msad' => new SectionBreakField(array( 'label' => 'Microsoft® Active Directory', - 'hint' => 'This section should be complete for Active - Directory domains', + 'hint' => $__('This section should be all that is required for Active Directory domains'), )), 'domain' => new TextboxField(array( - 'label' => 'Default Domain', - 'hint' => 'Default domain used in authentication and searches', - 'configuration' => array('size'=>40), + 'label' => $__('Default Domain'), + 'hint' => $__('Default domain used in authentication and searches'), + 'configuration' => array('size'=>40, 'length'=>60), + 'validators' => array( + function($self, $val) use ($__) { + if (strpos($val, '.') === false) + $self->addError( + $__('Fully-qualified domain name is expected')); + }), )), 'dns' => new TextboxField(array( - 'label' => 'DNS Servers', - 'hint' => '(optional) DNS servers to query about AD servers. + 'label' => $__('DNS Servers'), + 'hint' => $__('(optional) DNS servers to query about AD servers. Useful if the AD server is not on the same network as this web server or does not have its DNS configured to - point to the AD servers', + point to the AD servers'), 'configuration' => array('size'=>40), + 'validators' => array( + function($self, $val) use ($__) { + if (!$val) return; + $servers = explode(',', $val); + foreach ($servers as $s) { + if (!Validator::is_ip(trim($s))) + $self->addError(sprintf( + $__('%s: Expected an IP address'), $s)); + } + }), )), 'ldap' => new SectionBreakField(array( - 'label' => 'Generic configuration for LDAP', - 'hint' => 'Not necessary if Active Directory is configured above', + 'label' => $__('Generic configuration for LDAP'), + 'hint' => $__('Not necessary if Active Directory is configured above'), )), 'servers' => new TextareaField(array( 'id' => 'servers', - 'label' => 'LDAP servers', + 'label' => $__('LDAP servers'), 'configuration' => array('html'=>false, 'rows'=>2, 'cols'=>40), - 'hint' => 'Use "server" or "server:port". Place one server ' - .'entry per line', + 'hint' => $__('Use "server" or "server:port". Place one server entry per line'), )), 'tls' => new BooleanField(array( 'id' => 'tls', - 'label' => 'Use TLS', + 'label' => $__('Use TLS'), 'configuration' => array( - 'desc' => 'Use TLS to communicate with the LDAP server') + 'desc' => $__('Use TLS to communicate with the LDAP server')) )), 'conn_info' => new SectionBreakField(array( - 'label' => 'Connection Information', - 'hint' => 'Useful only for information lookups. Not + 'label' => $__('Connection Information'), + 'hint' => $__('Useful only for information lookups. Not necessary for authentication. NOTE that this data is not - necessary if your server allows anonymous searches' + necessary if your server allows anonymous searches') )), 'bind_dn' => new TextboxField(array( - 'label' => 'Search User', - 'hint' => 'Bind DN (distinguised name) to bind to the LDAP - server as in order to perform searches', - 'configuration' => array('size'=>40, 'length'=>80), + 'label' => $__('Search User'), + 'hint' => $__('Bind DN (distinguished name) to bind to the LDAP + server as in order to perform searches'), + 'configuration' => array('size'=>40, 'length'=>120), )), 'bind_pw' => new TextboxField(array( 'widget' => 'PasswordWidget', - 'label' => 'Password', - 'hint' => "Password associated with the DN's account", + 'label' => $__('Password'), + 'validator' => 'noop', + 'hint' => $__("Password associated with the DN's account"), 'configuration' => array('size'=>40), )), 'search_base' => new TextboxField(array( - 'label' => 'Search Base', - 'hint' => 'Used when searching for users', - 'configuration' => array('size'=>70, 'length'=>80), + 'label' => $__('Search Base'), + 'hint' => $__('Used when searching for users'), + 'configuration' => array('size'=>70, 'length'=>120), )), 'schema' => new ChoiceField(array( - 'label' => 'LDAP Schema', - 'hint' => 'Layout of the user data in the LDAP server', + 'label' => $__('LDAP Schema'), + 'hint' => $__('Layout of the user data in the LDAP server'), 'default' => 'auto', 'choices' => array( - 'auto' => '-- Automatically Detect --', - 'msad' => 'Microsoft Active Directory', - '2307' => 'Posix Account', + 'auto' => '— '.$__('Automatically Detect').' —', + 'msad' => 'Microsoft® Active Directory', + '2307' => 'Posix Account (rfc 2307)', ), )), + + 'auth' => new SectionBreakField(array( + 'label' => $__('Authentication Modes'), + 'hint' => $__('Authentication modes for clients and staff + members can be enabled independently'), + )), + 'auth-staff' => new BooleanField(array( + 'label' => $__('Staff Authentication'), + 'default' => true, + 'configuration' => array( + 'desc' => $__('Enable authentication of staff members') + ) + )), + 'auth-client' => new BooleanField(array( + 'label' => $__('Client Authentication'), + 'default' => false, + 'configuration' => array( + 'desc' => $__('Enable authentication of clients') + ) + )), ); } function pre_save(&$config, &$errors) { require_once('include/Net/LDAP2.php'); + list($__, $_N) = self::translate(); global $ost; if ($ost && !extension_loaded('ldap')) { - $ost->setWarning('LDAP extension is not available'); + $ost->setWarning($__('LDAP extension is not available')); + $errors['err'] = $__('LDAP extension is not available. Please + install or enable the `php-ldap` extension on your web + server'); return; } @@ -92,19 +146,22 @@ function pre_save(&$config, &$errors) { if (!($servers = LDAPAuthentication::autodiscover($config['domain'], preg_split('/,?\s+/', $config['dns'])))) $this->getForm()->getField('servers')->addError( - "Unable to find LDAP servers for this domain. Try giving + $__("Unable to find LDAP servers for this domain. Try giving an address of one of the DNS servers or manually specify - the LDAP servers for this domain below."); + the LDAP servers for this domain below.")); } else { if (!$config['servers']) $this->getForm()->getField('servers')->addError( - "No servers specified. Either specify a Active Directory - domain or a list of servers"); + $__("No servers specified. Either specify a Active Directory + domain or a list of servers")); else { $servers = array(); foreach (preg_split('/\s+/', $config['servers']) as $host) - $servers[] = array('host' => $host); + if (preg_match('/((ldaps?:\/\/)?([^:]+)):(\d{1,4})/', $host, $matches)) + $servers[] = array('host' => $matches[1], 'port' => (int) $matches[4]); + else + $servers[] = array('host' => $host); } } $connection_error = false; @@ -131,8 +188,19 @@ function pre_save(&$config, &$errors) { $c = new Net_LDAP2($info); $r = $c->bind(); if (PEAR::isError($r)) { - $connection_error = - $r->getMessage() .': Unable to bind to '.$info['host']; + if (false === strpos($config['bind_dn'], '@') + && false === strpos($config['bind_dn'], ',dc=')) { + // Assume Active Directory, add the default domain in + $config['bind_dn'] .= '@' . $config['domain']; + $info['bind_dn'] = $config['bind_dn']; + $c = new Net_LDAP2($info); + $r = $c->bind(); + } + } + if (PEAR::isError($r)) { + $connection_error = sprintf($__( + '%s: Unable to bind to server %s'), + $r->getMessage(), $info['host']); } else { $connection_error = false; @@ -141,7 +209,7 @@ function pre_save(&$config, &$errors) { } if ($connection_error) { $this->getForm()->getField('servers')->addError($connection_error); - $errors['err'] = 'Unable to connect any listed LDAP servers'; + $errors['err'] = $__('Unable to connect any listed LDAP servers'); } if (!$errors && $config['bind_pw']) @@ -152,7 +220,7 @@ function pre_save(&$config, &$errors) { global $msg; if (!$errors) - $msg = 'LDAP configuration updated successfully'; + $msg = $__('LDAP configuration updated successfully'); return !$errors; } diff --git a/auth-ldap/plugin.php b/auth-ldap/plugin.php index 9554e5b0..2757c198 100644 --- a/auth-ldap/plugin.php +++ b/auth-ldap/plugin.php @@ -1,15 +1,18 @@ 'auth:ldap', # notrans - 'version' => '0.1', - 'name' => 'LDAP Authentication and Lookup', + 'version' => '0.6.2', + 'name' => /* trans */ 'LDAP Authentication and Lookup', 'author' => 'Jared Hancock', - 'description' => 'Provides a configurable authentication backend + 'description' => /* trans */ 'Provides a configurable authentication backend which works against Microsoft Active Directory and OpenLdap servers', 'url' => 'http://www.osticket.com/plugins/auth/ldap', - 'plugin' => 'authentication.php:LdapAuthPlugin' + 'plugin' => 'authentication.php:LdapAuthPlugin', + 'map' => array( + 'pear-pear.php.net/net_ldap2' => 'include' + ), ); ?> diff --git a/auth-oauth2/auth.php b/auth-oauth2/auth.php new file mode 100644 index 00000000..1d86fd28 --- /dev/null +++ b/auth-oauth2/auth.php @@ -0,0 +1,111 @@ +append( + url_get("$url", function () use($id) { + $id = $id ?: $_SESSION['ext:bk:id']; + if (isset($_GET['code']) + && isset($_GET['state']) + && ($bk=self::getAuthBackend($id))) { + $bk->callback($_GET, $id); + } + // Authentication failed or downstream failed to redirect user. + Http::redirect(ROOT_PATH); + }) + ); + }); + } + + private static function registerAuthBackends(PluginConfig $config) { + + $target = $config->get('auth_target') ?: 'none'; + if (in_array($target, array('all', 'agents'))) { + StaffAuthenticationBackend::register( + new OAuth2StaffAuthBackend($config)); + } + if (in_array($target, array('all', 'users'))) { + UserAuthenticationBackend::register( + new OAuth2UserAuthBackend($config)); + } + } + + private static function getAuthBackend($id) { + // Authentication backends + $bk = AuthenticationBackend::lookupBackend($id); + if ($bk instanceof OAuth2AuthBackend) + return $bk; + // OAuth2 Authorization backends + if (($bk=OAuth2AuthorizationBackend::getBackend($id))) + return $bk; + // OAuth2 Authentication backends + if (($bk=OAuth2AuthenticationBackend::getBackend($id))) + return $bk; + } + + public function getNewInstanceOptions() { + $newOptions = []; + foreach (OAuth2AuthenticationBackend::allRegistered() as $bk) { + $newOptions[] = [ + 'name' => $bk::$name, + 'href' => sprintf('plugins.php?id=%d&provider=%s&a=add-instance#instances', + $this->getId(), $bk::$id), + 'class' => '', + 'icon' => $bk::$icon, + ]; + } + return $newOptions; + } + + public function getNewInstanceDefaults($options) { + $defaults = ['auth_type' => 'auth']; + if (isset($options['provider']) + && ($id=$options['provider']) + && (($bk=OAuth2AuthenticationBackend::getBackend($id)))) + $defaults += $bk->getDefaults(); + + return $defaults; + } + + public function init() { + // Register API Endpoint + self::registerEndpoint(); + // Register Oauth2 Authorization Providers + OAuth2ProviderBackend::registerProviders([ + 'plugin_id' => $this->getId()]); + } + + public function bootstrap() { + // Get sideloaded instance config - this is neccessary for backwards + // compatibility before multi-instance support + $config = $this->getConfig(); + // Only register Authentication backends Authorization Backends are + // done on-demand via Email Account interface + if ($config && $config->isAuthen()) + self::registerAuthBackends($config); + } +} + diff --git a/auth-oauth2/config.php b/auth-oauth2/config.php new file mode 100644 index 00000000..e579a768 --- /dev/null +++ b/auth-oauth2/config.php @@ -0,0 +1,375 @@ +get('auth_type', 'auth'); + } + + public function isAutho() { + return ($this->getAuthType() + && !strcasecmp($this->getAuthType(), 'autho')); + } + + public function isAuthen() { + return !$this->isAutho(); + } + + public function getName() { + return $this->get('auth_name'); + } + + public function getServiceName() { + return $this->get('auth_service'); + } + + public function getClientId() { + return $this->get('clientId'); + } + + public function getClientSecret() { + return $this->get('clientSecret'); + } + + public function getScopes() { + return array_map('trim', + explode(',', $this->get('scopes', []))); + } + + public function getAuthorizationUrl() { + return $this->get('urlAuthorize'); + } + + public function getAccessTokenUrl() { + return $this->get('urlAccessToken'); + } + + public function getRedirectUri() { + return $this->get('redirectUri'); + } + + public function getResourceOwnerDetailstUrl() { + return $this->get('urlResourceOwnerDetails'); + } + + public function getAttributeFor($name, $default=null) { + return $this->get("attr_$name", $default); + } + + public function getClientSettings() { + $scopes = $this->getScopes(); + $settings = [ + 'clientId' => $this->getClientId(), + 'clientSecret' => $this->getClientSecret(), + 'redirectUri' => $this->getRedirectUri(), + 'urlAuthorize' => $this->getAuthorizationUrl(), + 'urlAccessToken' => $this->getAccessTokenUrl(), + 'urlResourceOwnerDetails' => $this->getResourceOwnerDetailstUrl(), + 'scopes' => $scopes, + ]; + + // Use comma separator when we have more than one scopes - this is + // because scopes string is comma exploded. + if ($scopes && count($scopes) > 1) + $settings['scopeSeparator'] = ','; + + return $settings; + } + + function translate() { + return Plugin::translate('auth-oauth2'); + } + + function getAllOptions() { + list($__, $_N) = self::translate(); + return array( + 'auth_settings' => new SectionBreakField(array( + 'label' => $__('Settings'), + 'hint' => $__('General settings'), + )), + 'auth_name' => new TextboxField(array( + 'label' => $__('Name'), + 'hint' => $__('IdP Name e.g Google'), + 'required' => true, + 'configuration' => array( + 'size' => 34, + 'length' => 125 + ) + ) + ), + 'auth_target' => new ChoiceField(array( + 'label' => $__('Authentication Target'), + 'hint' => $__('Target Audience'), + 'required' => true, + 'choices' => array( + 'none' => $__('None (Disabled)'), + 'agents' => $__('Agents Only'), + 'users' => $__('End Users Only'), + 'all' => $__('Agents and End Users'), + ), + 'default' => 'none', + 'visibility' => new VisibilityConstraint( + new Q(array('auth_type__eq' => 'auth')), + VisibilityConstraint::HIDDEN + ), + ) + ), + 'auth_service' => new TextboxField(array( + 'label' => $__('Authentication Label'), + 'hint' => $__('Sign in With label'), + 'required' => true, + 'configuration' => array( + 'size' => 34, + 'length' => 64 + ), + 'visibility' => new VisibilityConstraint( + new Q(array('auth_type__eq' => 'auth')), + VisibilityConstraint::HIDDEN + ), + ) + ), + 'idp' => new SectionBreakField(array( + 'label' => $__('OAuth2 Provider (IdP) Details'), + 'hint' => $__('Authorization instances can be added via Email Account interface'), + )), + 'auth_type' => new ChoiceField(array( + 'label' => $__('Type'), + 'hint' => $__('OAuth2 Client Type'), + 'required' => true, + 'choices' => array( + 'auth' => $__('Authentication'), + 'autho' => $__('Authorization'), + ), + 'configuration' => array( + 'disabled' => true, + ), + 'default' => $this->getAuthType(), + ) + ), + 'redirectUri' => new TextboxField( + array( + 'label' => $__('Redirect URI'), + 'hint' => $__('Callback Endpoint'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'validators' => function($f, $v) { + if (!preg_match('[\.*(/api/auth/oauth2)$]isu', $v)) + $f->addError(__('Must be a valid API endpont')); + }, + 'default' => OAuth2Plugin::callback_url(), + ) + ), + 'clientId' => new TextboxField( + array( + 'label' => $__('Client Id'), + 'hint' => $__('Client Identifier (Id)'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0, + 'placeholder' => $__('Client Id') + ) + ) + ), + 'clientSecret' => new PasswordField( + array( + 'widget' => 'PasswordWidget', + 'label' => $__('Client Secret'), + 'hint' => $__('Client Secret'), + 'required' => !$this->getClientSecret(), + 'validator' => 'noop', + 'configuration' => array( + 'size' => 64, + 'length' => 0, + 'key' => $this->getNamespace(), + 'placeholder' => $this->getClientSecret() + ? str_repeat('•', strlen($this->getClientSecret())) + : $__('Client Secret'), + ) + ) + ), + 'urlAuthorize' => new TextboxField( + array( + 'label' => $__('Authorization Endpoint'), + 'hint' => $__('Authorization URL'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'default' => '', + ) + ), + 'urlAccessToken' => new TextboxField( + array( + 'label' => $__('Token Endpoint'), + 'hint' => $__('Access Token URL'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'default' => '', + ) + ), + 'urlResourceOwnerDetails' => new TextboxField( + array( + 'label' => $__('Resource Details Endpoint'), + 'hint' => $__('User Details URL'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'default' => '', + ) + ), + 'scopes' => new TextboxField( + array( + 'label' => $__('Scopes'), + 'hint' => $__('Comma or Space separated scopes depending on IdP requirements'), + 'required' => true, + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + ) + ), + 'attr_mapping' => new SectionBreakField(array( + 'label' => $__('User Attributes Mapping'), + 'hint' => $__('Consult your IdP documentation for supported attributes and scope'), + )), + 'attr_username' => new TextboxField(array( + 'label' => $__('User Identifier'), + 'hint' => $__('Unique User Identifier - Username or Email address'), + 'required' => true, + 'default' => 'email', + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'visibility' => new VisibilityConstraint( + new Q(array('auth_type__eq' => 'auth')), + VisibilityConstraint::HIDDEN + ), + )), + 'attr_givenname' => new TextboxField(array( + 'label' => $__('Given Name'), + 'hint' => $__('First name'), + 'default' => 'givenname', + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'visibility' => new VisibilityConstraint( + new Q(array('auth_type__eq' => 'auth')), + VisibilityConstraint::HIDDEN + ), + + )), + 'attr_surname' => new TextboxField(array( + 'label' => $__('Surname'), + 'hint' => $__('Last name'), + 'default' => 'surname', + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + 'visibility' => new VisibilityConstraint( + new Q(array('auth_type__eq' => 'auth')), + VisibilityConstraint::HIDDEN + ), + )), + 'attr_email' => new TextboxField(array( + 'label' => $__('Email Address'), + 'hint' => $__('Email address required to auto-create User accounts. Agents must already exist.'), + 'default' => 'email', + 'configuration' => array( + 'size' => 64, + 'length' => 0 + ), + )), + ); + } + + function getOptions() { + return $this->getAllOptions(); + } + + function getFields() { + list($__, $_N) = self::translate(); + switch ($this->getAuthType()) { + case 'autho': + // Authorization fields + $base = array_flip(['idp', 'auth_type', 'redirectUri', 'clientId', 'clientSecret', + 'urlAuthorize', 'urlAccessToken', + 'urlResourceOwnerDetails', 'scopes', 'attr_email', + ]); + $fields = array_merge($base, array_intersect_key( + $this->getAllOptions(), $base)); + $fields['attr_email'] = new TextboxField([ + 'label' => $__('Email Address Attribute'), + 'hint' => $__('Please consult your provider docs for the correct attribute to use'), + 'required' => true, + ]); + break; + case 'auth': + default: + $fields = $this->getOptions(); + break; + } + return $fields; + } + + function pre_save(&$config, &$errors) { + list($__, $_N) = self::translate(); + // Authorization instances can only be managed via Email Account + // interface at the moment. + if ($this->isAutho()) + $errors['err'] = $__('Authorization instances can only be managed via Email Account interface at the moment'); + return !count($errors); + } + + public function getFormOptions() { + list($__, $_N) = self::translate(); + return [ + 'notice' => $this->isAutho() + ? $__('Authorization instances can only be updated via Email Account interface') + : ($this->getClientId() + ? $__('Be careful - changes might break Authentication of the Target Audience') + : '' + ), + ]; + } +} + +class OAuth2EmailConfig extends OAuth2Config { + + public function getAuthType() { + return $this->get('auth_type', 'autho'); + } + + // Notices are handled at Email Account level + public function getFormOptions() { + return []; + } + + // This is necessay so the parent can reject updates on Autho instances via plugins + // intervace which is doesn't have re-authorization capabilities at the + // moment. + function pre_save(&$config, &$errors) { + return true; + } + + function getFields() { + // Remove fields not needed on the Email interface + return array_diff_key(parent::getFields(), + array_flip(['idp', 'auth_type']) + ); + } +} diff --git a/auth-oauth2/oauth2.php b/auth-oauth2/oauth2.php new file mode 100644 index 00000000..62ca339e --- /dev/null +++ b/auth-oauth2/oauth2.php @@ -0,0 +1,691 @@ + + Copyright (c) 2006-2022 osTicket + https://osticket.com + + Credit: + * https://github.com/thephpleague/oauth2-client + * Interwebz + + Released under the GNU General Public License WITHOUT ANY WARRANTY. + See LICENSE.TXT for details. + + vim: expandtab sw=4 ts=4 sts=4: +**********************************************************************/ +include_once 'auth.php'; + +use League\OAuth2\Client\Provider\GenericProvider; + +/** + * OAuth2AuthBackend + * + * Interface class for OAuth2 authentication backends + * + * Not entirely necessary but used to decorate classes to make it easy to + * test if it's an instance of desired backend. + * + */ + +interface OAuth2AuthBackend { + + /** + * Function: handleCallback + * + * Called when we receive OAuth2 auth code + */ + function callBack($resp, $ref=''); + + /** + * Function: redirect util + * + * Called to redirect user to either internal or external urls. + */ + function redirectTo($url); + function setState($state); + function getState(); + function resetState(); + function getAccessToken($code); +} + +/** + * OAuth2AuthenticationTrait + * + * Trait class with the core OAuth2 authentication functions used by + * downstream backends. + * + */ +trait OAuth2AuthenticationTrait { + // OAuth2 Id Provider + private $provider; + // debug mode flag + private $debug = false; + + // SESSION store for data like AuthNRequestID + private $session; + // Configuration store + protected $config; + // Supported attributes mapped to scopes - hard coded for now + private $attributes = ['username', 'givenname', 'surname', 'email']; + + function __construct($config, $provider=null) { + $this->config = $config; + // Session data stash for backends + $this->session = &$_SESSION[':oauth'][$this->getId()]; + if ($provider instanceof OAuth2AuthorizationBackend) + $this->provider = $provider; + else + $this->provider = new GenericOauth2Provider(); + + // Get Oauth Client based on provider + $this->client = $this->provider->getClient($config); + } + + function callback($resp, $ref=null) { + try { + if ($this->getState() == $resp['state'] + && ($token=$this->getAccessToken($resp['code'])) + && ($owner=$this->client->getResourceOwner($token)) + && ($attrs=$this->mapAttributes($owner->toArray()))) { + $this->resetState(); + // Attempt to signIn the user based on returned attributes + $result = $this->signIn($attrs); + if ($result instanceof AuthenticatedUser) { + // SignIn successful - login the user and redirect to + // desired panel + if ($this->login($result, $this)) + $this->onSignIn(); + } + } + } catch (Exception $ex) { + return false; + } + } + + function getId() { + return static::$id; + } + + function getName() { + return $this->config->getName(); + } + + function getServiceName() { + return $this->config->getServiceName(); + } + + public function setState($state) { + $this->session['AuthState'] = $state; + } + + public function resetState() { + $this->setState(''); + } + + public function getState() { + return $this->session['AuthState']; + } + + private function mapAttributes(array $result) { + // Mapout the supported attributes only + $attributes = array(); + $result = array_change_key_case($result, CASE_LOWER); + foreach ($this->attributes as $attr) { + if (!($key=strtolower($this->config->getAttributeFor($attr)))) + continue; + $attributes[$attr] = $result[$key] ?: null; + } + // Use email as username if none is provided or vice versa! + if (!isset($attributes['username']) && isset($attributes['email'])) + $attributes['username'] = $attributes['email']; + elseif (!isset($attributes['email']) + && isset($attributes['username']) + && Validator::is_email($attributes['username'])) + $attributes['email'] = $attributes['username']; + + return $attributes; + } + + public function getAccessToken($code) { + return $this->client->getAccessToken('authorization_code', + ['code' => $code]); + } + + public function refreshAccessToken($refreshToken) { + return $this->client->getAccessToken('refresh_token', + ['refresh_token' => $refreshToken]); + } + + public function triggerAuth() { + parent::triggerAuth(); + // Regenerate OAuth2 auth request + $authUrl = $this->client->getAuthorizationUrl(); + // Get the state generated for you and store it to the session. + $this->setState($this->client->getState()); + $this->redirectTo($authUrl); + } + + + function redirectTo($url) { + // No cache redirect + header('Pragma: no-cache'); + header('Cache-Control: no-cache, must-revalidate'); + Http::redirect($url); + } + + static function signOut($user) { + parent::signOut($user); + } + + abstract function signIn($attrs); + abstract function onSignIn(); +} + +/** + * OAuth2StaffAuthBackend + * + * Provides OAuth2 authentication backend for agents + */ +class OAuth2StaffAuthBackend extends ExternalStaffAuthenticationBackend +implements OAuth2AuthBackend { + use OAuth2AuthenticationTrait; + static $id = "oauth2.agent"; + static $name = "OAuth2"; + static $service_name = "OAuth2"; + + private function signIn($attrs) { + if ($attrs && isset($attrs['username'])) { + if (($staff = StaffSession::lookup($attrs['username'])) + && $staff->getId()) { + // Older versions + if (!$staff instanceof StaffSession) + $staff = new StaffSession($staff->getId()); + + return $staff; + } else { + $_SESSION['_staff']['auth']['msg'] = sprintf('%s (%s)', + 'Have your administrator create a local account', + Format::htmlchars($attrs['username'])); + } + } + } + + private function onSignIn() { + $this->redirectTo($_SESSION['_staff']['auth']['dest'] ?: osTicket::get_base_url().'scp/'); + } + +} + + +/** + * OAuth2UserAuthBackend + * + * Provides OAuth2 authentication backend for users + */ +class OAuth2UserAuthBackend extends ExternalUserAuthenticationBackend +implements OAuth2AuthBackend { + use OAuth2AuthenticationTrait; + static $id = "oauth2.user"; + static $name = "OAuth2"; + static $service_name = "OAuth2"; + + private function signIn($attrs) { + if ($attrs && isset($attrs['username'])) { + if (($acct = ClientAccount::lookupByUsername($attrs['username'])) + && $acct->getId()) + return new ClientSession(new EndUser($acct->getUser())); + // Auto-register user if possible + $email = $attrs['email'] ?: $attrs['username']; + if (Validator::is_email($email)) { + if (!($name = trim(sprintf('%s %s', $attrs['givenname'], $attrs['surname'])))) + $name = $attrs['username']; + $info = ['email' => $email, 'name' => $name]; + $req = new ClientCreateRequest($this, $attrs['username'], $info); + return $req->attemptAutoRegister(); + } + } + } + + private function onSignIn() { + $this->redirectTo($_SESSION['_client']['auth']['dest'] ?: osTicket::get_base_url()); + } +} + +/* + * OAuth Email Auth Backend + * + */ +class OAuth2EmailAuthBackend implements OAuth2AuthBackend { + use OAuth2AuthenticationTrait; + static $id = "oauth2.emailautho"; + private $options; + public $account; + + const ERR_UNKNOWN = 0; + const ERR_EMAIL_ATTR = 1; + const ERR_EMAIL_MISMATCH = 2; + const ERR_REFRESH_TOKEN = 3; + + private function isStrict() { + // TODO: Require osTicket v1.18 and delegate strict checking to + // the email account ($this->account->isStrict()) + // For now the flag is being set via the provider by overloading + // backend id + return ($this->provider && $this->provider->isStrict()); + } + + function getEmailId() { + return $this->account->getEmailId(); + } + + function getEmail() { + return $this->account->getEmail(); + } + + function getEmailAddress() { + return $this->account->email->getEmail(); + } + + private function updateCredentials($info, &$errors) { + return $this->account->updateCredentials( + $this->provider->getId(), $info, $errors); + } + + public function callback($resp, $ref=null) { + $errors = []; + $err = sprintf('%s_auth_bk', $this->account->getType()); + try { + if ($this->getState() == $resp['state'] + && ($token=$this->getAccessToken($resp['code'])) + && ($owner=$this->client->getResourceOwner($token)) + && ($attrs=$this->mapAttributes($owner->toArray()))) { + $this->resetState(); + $info = [ + 'access_token' => $token->getToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires' => $token->getExpires(), + 'resource_owner_id' => $token->getResourceOwnerId(), + 'resource_owner_email' => $attrs['email'], + ]; + + if (!isset($attrs['email'])) + $errors[$err] = $this->error_msg(self::ERR_EMAIL_ATTR, $attrs); + elseif (!$info['refresh_token']) + $errors[$err] = $this->error_msg(self::ERR_REFRESH_TOKEN); + elseif (!$this->signIn($attrs) && $this->isStrict()) { + // On strict mode email mismatch is an error + // TODO: Move Strict checking to osTiket core on + // credentials update. + $errors[$err] = $this->error_msg(self::ERR_EMAIL_MISMATCH, $attrs); + } + // Update the credentials if no validation errors + if (!$errors + && !$this->updateCredentials($info, $errors) + && !isset($errors[$err])) + $errors[$err] = $this->error_msg(self::ERR_UNKNOWN); + } + } catch (Exception $ex) { + $errors[$err] = $ex->getMessage(); + } + + // stash the results before redirecting + $email = $this->getEmail(); + // TODO: check if email implements StashableTrait + if ($errors) + $email->stash('errors', $errors); + else + $email->stash('notice', sprintf('%s: %s', + $this->account->getType(), + __('OAuth2 Authorization Successful') + )); + // redirect back to email page + $this->onSignIn(); + } + + public function triggerAuth() { + // Regenerate OAuth2 auth request + $urlOptions = $this->provider->getUrlOptions() ?: []; + $authUrl = $this->client->getAuthorizationUrl($urlOptions); + // Get the state generated for you and store it to the session. + $this->setState($this->client->getState()); + $this->redirectTo($authUrl); + } + + private function signIn($attrs) { + return !strcasecmp($attrs['email'], $this->getEmailAddress()); + } + + private function onSignIn() { + $this->redirectTo(osTicket::get_base_url() + .sprintf('scp/emails.php?id=%d#%s', + $this->getEmailId(), + $this->account->getType()) + ); + } + + private function error_msg($errorno, $attrs=[]) { + switch ($errorno) { + case self::ERR_EMAIL_ATTR: + return __('Invalid Email Atrribute'); + break; + case self::ERR_EMAIL_MISMATCH: + return sprintf(__('Email Mismatch: Expecting Authorization for %s not %s'), + $this->getEmailAddress(), + $attrs['email']); + break; + case self::ERR_REFRESH_TOKEN: + return __('Unable to obtain Refresh Token'); + break; + case self::ERR_UNKNOWN: + default: + return __('Unknown Error'); + } + } +} + +abstract class OAuth2ProviderBackend extends OAuth2AuthorizationBackend { + protected $config; + private $plugin; + private $plugin_id; + static $defaults = []; + + // Strict flag + private $strict = false; + + function __construct($options=[]) { + if (isset($options['plugin_id'])) + $this->plugin_id = (int) $options['plugin_id']; + } + + function isStrict() { + return (bool) $this->strict; + } + + function getId() { + return static::$id; + } + + function getName() { + return static::$name; + } + + function getPluginId() { + return $this->plugin_id; + } + + function getPlugin() { + if (!isset($this->plugin) && $this->plugin_id) + $this->plugin = PluginManager::lookup($this->plugin_id); + + return $this->plugin; + } + + function getConfig($instance=null, $vars=[]) { + if ($instance && !is_object($instance)) + $instance = $this->getPluginInstance($instance); + if (!isset($this->config) || $instance) { + $this->config = new OAuth2EmailConfig($instance ? + $instance->getNamespace() : null, $vars); + $this->config->setInstance($instance); + } + + return $this->config; + } + + function getConfigForm($vars, $id=null) { + $vars = $vars ?: $this->getDefaults(); + return $this->getConfig($id, $vars)->getForm($vars); + } + + function getDefaults() { + return static::$defaults ?: []; + } + + function getPluginInstance($id) { + return $this->getPlugin()->getInstance($id); + } + + function addPluginInstance($vars, &$errors) { + if (!($plugin=$this->getPlugin())) + return false; + // Add some default values not set on Basic Config + $vars = array_merge($vars, array_intersect_key($this->getDefaults(), + array_flip(['attr_username', 'attr_email', 'attr_givenname', + 'attr_surname']))); + return $plugin->addInstance($vars, $errors); + } + + function getEmailAuthBackend($id) { + list($auth, $a, $i, $strict) = self::parseId($id); + if (!strcasecmp($auth, $this->getId()) + && ($plugin=$this->getPlugin()) + && $plugin->isActive() + && ($instance=$this->getPluginInstance((int) $i)) + && ($config=$instance->getConfig()) + && ($account=EmailAccount::lookup((int) $a)) + && $account->isEnabled()) { + // Set strict flag + $this->strict = (bool) $strict; + $bk = new OAuth2EmailAuthBackend($config, $this); + $bk->account = $account; + return $bk; + } + } + + function refreshAccessToken($refreshToken, $id, &$errors) { + if (!$refreshToken || !($bk=$this->getEmailAuthBackend($id))) + return false; + + try { + $token = $bk->refreshAccessToken($refreshToken); + return array_filter([ + 'access_token' => $token->getToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires' => $token->getExpires() + ]); + } catch( Exception $ex) { + $errors['refresh_token'] = $ex->getMessage(); + } + } + + function triggerEmailAuth($id) { + if (!($bk=$this->getEmailAuthBackend($id))) + return false; + + $_SESSION['ext:bk:id'] = $id; + $bk->triggerAuth(); + } + + // We delegate call back to Email Authorization backend + function callback($resp, $id='') { + if (!$id || !($bk=$this->getEmailAuthBackend($id))) + return false; + + return $bk->callback($resp, $id); + } + + // Register Authentication Providers (Templates) + static function registerAuthenticationProviders($options=[]) { + OAuth2AuthenticationBackend::register(new + GoogleOAuth2Provider($options)); + OAuth2AuthenticationBackend::register(new + MicrosoftOAuth2Provider($options)); + OAuth2AuthenticationBackend::register(new + OktaOAuth2Provider($options)); + OAuth2AuthenticationBackend::register(new + OtherOAuth2Provider($options)); + } + + // Register Authorization Providers + static function registerEmailAuthoProviders($options=[]) { + OAuth2AuthorizationBackend::register(new + GoogleEmailOAuth2Provider($options)); + OAuth2AuthorizationBackend::register(new + MicrosoftEmailOAuth2Provider($options)); + OAuth2AuthorizationBackend::register(new + OtherEmailOAuth2Provider($options)); + } + + static function registerProviders($options=[]) { + self::registerEmailAuthoProviders($options); + self::registerAuthenticationProviders($options); + } + + abstract function getClient(PluginConfig $config); +} + +class OAuth2Client extends GenericProvider { + protected function getAuthorizationParameters(array $options) { + // Cleanup prompt conflicts + // approval_prompt, hardcoded upstream, nolonger works for Google + // when attempting to force new refresh token. + $options = parent::getAuthorizationParameters($options); + if (isset($options['prompt']) && isset($options['approval_prompt'])) + unset($options['approval_prompt']); + + return $options; + } +} + + +class GenericOauth2Provider extends OAuth2ProviderBackend { + static $id = 'oauth2:other'; + static $name = 'OAuth2 - Other'; + static $defaults = []; + static $urlOptions = []; + + + function getUrlOptions() { + return static::$urlOptions; + } + + function getClient(PluginConfig $config) { + return new OAuth2Client($config->getClientSettings()); + } +} + +class OtherOauth2Provider extends GenericOauth2Provider { + static $id = 'oauth2:other'; + static $name = 'OAuth2 - Other'; + static $icon = 'icon-plus-sign'; + static $defaults = []; + static $urlOptions = []; + +} + +// Authentication Providers +class GoogleOauth2Provider extends GenericOauth2Provider { + static $id = 'oauth2:google'; + static $name = 'Google'; + static $icon = 'icon-google-plus-sign'; + static $defaults = [ + 'urlAuthorize' => 'https://accounts.google.com/o/oauth2/v2/auth', + 'urlAccessToken' => 'https://oauth2.googleapis.com/token', + 'urlResourceOwnerDetails' => 'https://www.googleapis.com/oauth2/v2/userinfo', + 'scopes' => 'profile https://www.googleapis.com/auth/userinfo.email', + 'auth_name' => 'Google', + 'auth_service' => 'Google', + 'attr_username' => 'email', + 'attr_email' => 'email', + 'attr_givenname' => 'given_name', + 'attr_surname' => 'family_name', + ]; +} + +class MicrosoftOauth2Provider extends GenericOauth2Provider { + static $id = 'oauth2:microsoft'; + static $name = 'Microsoft'; + static $icon = 'icon-windows'; + static $defaults = [ + 'urlAuthorize' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + 'urlAccessToken' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + 'urlResourceOwnerDetails' => 'https://graph.microsoft.com/v1.0/me', + 'scopes' => 'https://graph.microsoft.com/.default', + 'auth_name' => 'Microsoft', + 'auth_service' => 'Azure', + 'attr_username' => 'userPrincipalName', + 'attr_email' => 'mail', + 'attr_givenname' => 'givenname', + 'attr_surname' => 'surname', + ]; +} + +class OktaOauth2Provider extends GenericOauth2Provider { + static $id = 'oauth2:okta'; + static $name = 'Okta'; + static $icon = 'icon-circle-blank'; + static $defaults = [ + 'urlAuthorize' => 'https://${yourOktaDomain}/oauth2/v1/authorize', + 'urlAccessToken' => 'https://${yourOktaDomain}/oauth2/v1/token', + 'urlResourceOwnerDetails' => 'https://${yourOktaDomain}/oauth2/v1/userinfo', + 'scopes' => 'openid profile email', + 'auth_name' => 'Okta', + 'auth_service' => 'Okta', + 'attr_username' => 'userName', + 'attr_email' => 'email', + 'attr_givenname' => 'given_name', + 'attr_surname' => 'family_name', + ]; +} + +// Authorization Email OAuth Providers +class GenericEmailOauth2Provider extends GenericOauth2Provider { + function getPluginInstance($id) { + $i = parent::getPluginInstance($id); + // Set config class for Email Authorization Providers + $i->setConfigClass('OAuth2EmailConfig'); + return $i; + } +} + +class OtherEmailOauth2Provider extends GenericEmailOauth2Provider { + static $id = 'oauth2:othermail'; + static $name = 'OAuth2 - Other Provider'; + static $defaults = []; + static $urlOptions = []; +} + +class GoogleEmailOauth2Provider extends GenericEmailOauth2Provider { + static $id = 'oauth2:gmail'; + static $name = 'OAuth2 - Google'; + static $defaults = [ + 'urlAuthorize' => 'https://accounts.google.com/o/oauth2/v2/auth', + 'urlAccessToken' => 'https://oauth2.googleapis.com/token', + 'urlResourceOwnerDetails' => 'https://www.googleapis.com/gmail/v1/users/me/profile', + 'scopes' => 'https://mail.google.com/', + 'attr_username' => 'emailaddress', + 'attr_email' => 'emailaddress', + 'attr_givenname' => 'given_name', + 'attr_surname' => 'family_name', + ]; + static $urlOptions = [ + 'responseType' => 'code', + 'access_type' => 'offline', + 'prompt' => 'consent', + ]; +} + +class MicrosoftEmailOauth2Provider extends GenericEmailOauth2Provider { + static $id = 'oauth2:msmail'; + static $name = 'OAuth2 - Microsoft'; + static $defaults = [ + 'urlAuthorize' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + 'urlAccessToken' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + 'urlResourceOwnerDetails' => 'https://outlook.office.com/api/v2.0/me', + 'scopes' => 'offline_access https://outlook.office.com/Mail.ReadWrite', + 'attr_username' => 'EmailAddress', + 'attr_email' => 'EmailAddress', + 'attr_givenname' => 'givenname', + 'attr_surname' => 'surname', + ]; + static $urlOptions = [ + 'tenant' => 'common', + 'accessType' => 'offline_access', + 'prompt' => 'select_account', + ]; +} +?> diff --git a/auth-oauth2/plugin.php b/auth-oauth2/plugin.php new file mode 100644 index 00000000..8a4ab9d7 --- /dev/null +++ b/auth-oauth2/plugin.php @@ -0,0 +1,27 @@ + 'auth:oath2', # notrans + 'version' => '0.6', + 'ost_version' => '1.17', # Require osTicket v1.17+ + 'name' => /* trans */ 'Oauth2 Client', + 'author' => 'Peter Rotich ', + 'description' => /* trans */ 'Provides a configurable Oauth2 authentication and authorization backends. backends.', + 'url' => 'http://www.osticket.com/', + 'plugin' => 'auth.php:OAuth2Plugin', + 'requires' => array( + "league/oauth2-client" => array( + "version" => "*", + "map" => array( + "league/oauth2-client/src" => 'lib/League/OAuth2/Client', + 'guzzlehttp/guzzle/src' => 'lib/GuzzleHttp', + 'guzzlehttp/psr7/src' => 'lib/GuzzleHttp/Psr7', + 'guzzlehttp/promises/src' => 'lib/GuzzleHttp/Promise', + 'psr/http-client/src' => 'lib/Psr/Http/Client', + 'psr/http-factory/src' => 'lib/Psr/Http/Factory', + 'psr/http-message/src' => 'lib/Psr/Http/Message', + + ) + ), + ), +); +?> diff --git a/auth-passthru/authenticate.php b/auth-passthru/authenticate.php new file mode 100644 index 00000000..8e399b42 --- /dev/null +++ b/auth-passthru/authenticate.php @@ -0,0 +1,101 @@ +getId()) { + if (!$user instanceof StaffSession) { + // osTicket <= v1.9.7 or so + $user = new StaffSession($user->getId()); + } + return $user; + } + + // TODO: Consider client sessions + } + } +} + +class UserHttpAuthentication extends UserAuthenticationBackend { + static $name = "HTTP Authentication"; + static $id = "passthru.client"; + + function supportsInteractiveAuthentication() { + return false; + } + + function signOn() { + if (isset($_SERVER['REMOTE_USER']) && !empty($_SERVER['REMOTE_USER'])) + // User was authenticated by the HTTP server + $username = $_SERVER['REMOTE_USER']; + elseif (isset($_SERVER['REDIRECT_REMOTE_USER']) + && !empty($_SERVER['REDIRECT_REMOTE_USER'])) + $username = $_SERVER['REDIRECT_REMOTE_USER']; + + if ($username) { + // Support ActiveDirectory domain specification with either + // "user@domain" or "domain\user" formats + if (strpos($username, '@') !== false) + list($username, $domain) = explode('@', $username, 2); + elseif (strpos($username, '\\') !== false) + list($domain, $username) = explode('\\', $username, 2); + $username = trim(strtolower($username)); + + if ($acct = ClientAccount::lookupByUsername($username)) { + if (($client = new ClientSession(new EndUser($acct->getUser()))) + && $client->getId()) + return $client; + } + else { + // No such account. Attempt a lookup on the username + $users = parent::searchUsers($username); + if (!is_array($users)) + return; + + foreach ($users as $u) { + if (0 === strcasecmp($u['username'], $username) + || 0 === strcasecmp($u['email'], $username)) + // User information matches HTTP username + return new ClientCreateRequest($this, $username, $u); + } + } + } + } +} + +require_once(INCLUDE_DIR.'class.plugin.php'); +require_once('config.php'); +class PassthruAuthPlugin extends Plugin { + var $config_class = 'PassthruAuthConfig'; + + function bootstrap() { + $config = $this->getConfig(); + if ($config->get('auth-staff')) + StaffAuthenticationBackend::register('HttpAuthentication'); + if ($config->get('auth-client')) + UserAuthenticationBackend::register('UserHttpAuthentication'); + } +} diff --git a/auth-passthru/config.php b/auth-passthru/config.php new file mode 100644 index 00000000..e5f4669b --- /dev/null +++ b/auth-passthru/config.php @@ -0,0 +1,53 @@ + new SectionBreakField(array( + 'label' => $__('Authentication Modes'), + 'hint' => $__('Authentication modes for clients and staff + members can be enabled independently. Client discovery + can be supported via a separate backend (such as LDAP)'), + )), + 'auth-staff' => new BooleanField(array( + 'label' => $__('Staff Authentication'), + 'default' => true, + 'configuration' => array( + 'desc' => $__('Enable authentication of staff members') + ) + )), + 'auth-client' => new BooleanField(array( + 'label' => $__('Client Authentication'), + 'default' => false, + 'configuration' => array( + 'desc' => $__('Enable authentication and discovery of clients') + ) + )), + ); + } + + function pre_save(&$config, &$errors) { + global $msg; + + list($__, $_N) = self::translate(); + if (!$errors) + $msg = $__('Configuration updated successfully'); + + return true; + } +} diff --git a/auth-passthru/plugin.php b/auth-passthru/plugin.php index 4f484062..49093d03 100644 --- a/auth-passthru/plugin.php +++ b/auth-passthru/plugin.php @@ -1,51 +1,15 @@ getId()) - return $user; - - // TODO: Consider client sessions - } - } -} - -class PassthruAuthPlugin extends Plugin { - function bootstrap() { - AuthenticationBackend::register('HttpAuthentication'); - } -} - return array( 'id' => 'auth:passthru', # notrans - 'version' => '0.1', - 'name' => 'HTTP Passthru Authentication', + 'version' => '0.2', + 'name' => /* trans */ 'HTTP Passthru Authentication', 'author' => 'Jared Hancock', - 'description' => 'Allows for the HTTP server (Apache or IIS) to perform + 'description' => /* trans */ 'Allows for the HTTP server (Apache or IIS) to perform the authentication of the user. osTicket will match the username from the server authentication to a username defined internally', 'url' => 'http://www.osticket.com/plugins/auth/passthru', - 'plugin' => 'PassthruAuthPlugin' + 'plugin' => 'authenticate.php:PassthruAuthPlugin' ); ?> diff --git a/auth-password-policy/auth.php b/auth-password-policy/auth.php new file mode 100644 index 00000000..36879300 --- /dev/null +++ b/auth-password-policy/auth.php @@ -0,0 +1,225 @@ + new TextboxField(array( + 'required' => true, + 'label' => __('Minimum length'), + 'configuration' => array( + 'validator' => 'regex', + 'regex' => '/(^[1-9]|^[1-9][0-9]|^1[0-1][0-9]|^12[0-8])$/', + 'validator-error' => sprintf('%s %s', __('Minimum'), + __('length must be between 1 and 128')), + 'size' => 4, + ), + 'default' => 8, + 'hint' => __('Minimum characters required'), + )), + 'maxlength' => new TextboxField(array( + 'required' => true, + 'label' => __('Maximum length'), + 'configuration' => array( + 'validator' => 'regex', + 'regex' => '/(^[1-9]|^[1-9][0-9]|^1[0-1][0-9]|^12[0-8])$/', + 'validator-error' => sprintf('%s %s', __('Maximum'), + __('length must be between 1 and 128')), + 'size' => 4, + ), + 'default' => 128, + 'hint' => __('Minimum characters required'), + )), + // Classes of characters + 'classes' => new ChoiceField(array( + 'required' => true, + 'label' => __('Character classes required'), + 'choices' => array( + '2' => sprintf('%s (2)', __('Two')), + '3' => sprintf('%s (3)', __('Three')), + '4' => sprintf('%s (4)', __('Four')), + ), + 'default' => 3, + 'hint' => __('Require this number of character classes: upper, lower, number, and special characters'), + )), + // Entropy + 'entropy' => new ChoiceField(array( + 'required' => false, + 'label' => __('Password strength'), + 'choices' => array( + '' => __('Disable'), + '32' => sprintf('%s (32 bits)', __('Weak')), + '56' => sprintf('%s (56 bits)', __('Good')), + '80' => sprintf('%s (80 bits)', __('Strong')), + '108' => sprintf('%s (108 bits)', __('Awesome')), + ), + 'default' => '', + 'hint' => sprintf('%s %s', + __('Enforce minimum password entropy.'), + __('See the wikipedia page for password strength for more reading on entropy')), + )), + // Enforcement + 'onlogin' => new BooleanField(array( + 'required' => false, + 'label' => __('Enforce on login'), + 'default' => false, + 'configuration'=>array( + 'desc' => __('Enforce password policies on login') + ), + 'hint' => __('Enforce password policies the next time a user login.') + )), + // Reuse + 'reuse' => new BooleanField(array( + 'required' => false, + 'label' => __('Password reuse'), + 'default' => false, + 'configuration'=>array( + 'desc' => __('Allow reuse') + ), + 'hint' => __('Allow password reuse') + )), + // Expiration + 'expires' => new ChoiceField(array( + 'required' => false, + 'label' => __('Password expiration'), + 'choices' => array( + '' => __('Never expires'), + '30' => __('30 days'), + '60' => __('60 days'), + '90' => __('90 days'), + '180' => __('180 days'), + '365' => __('365 days'), + ), + 'default' => '', + 'hint' => __('Password reset frequency') + )), + ); + } + + function pre_save(&$config, &$errors) { + if ($config['length'] >= $config['maxlength']) { + $this->getForm()->getField('length')->addError( + __("Minimum length must be smaller than Maximum length")); + $errors['err'] = __('Unable to update the Instance'); + } + + global $msg; + if (!$errors) + $msg = __('Instance updated successfully'); + + return !$errors; + } +} + +class PasswordManagementPolicy +extends PasswordPolicy { + var $config; + static $id = 'ppp'; + static $name = /* @trans */ "Password Management Plugin"; + + function __construct($config) { + $this->config = $config; + } + + function onLogin($user, $password) { + if (is_a($user, 'RegisteredUser')) + return; + + // Check password length and strength + if ($this->config->get('onlogin')) + $this->processPassword($password); + + // Check password expiration + if ($this->config->get('expires') + && ($time = $user->getPasswdResetTimestamp()) + && ($time < (time()-($this->config->get('expires')*86400)))) + throw new ExpiredPassword(__('Expired Password')); + } + + function onSet($password, $current=false) { + return $this->processPassword($password, $current); + } + + private function processPassword($password, $current=false) { + + // Current vs. new password + if ($current + && !$this->config->get('reuse') + && 0 === strcasecmp($passwd, $current)) { + throw new BadPassword( + __('New password MUST be different from the current password!')); + } + + // Password length + $pwdlen = mb_strlen($password); + if ($pwdlen < $this->config->get('length')) { + throw new BadPassword( + sprintf(__('Password is too short — must be %d characters'), + $this->config->get('length')) + ); + } elseif ($pwdlen > $this->config->get('maxlength')) { + throw new BadPassword( + sprintf(__('Password is too long — must be a maximum of %d characters'), + $this->config->get('maxlength')) + ); + } + + // Class of characters + if ($this->config->get('classes')) { + if (preg_match('/\p{Ll}/u', $password)) + $classes++; + if (preg_match('/\p{Lu}/u', $password)) + $classes++; + if (preg_match('/\p{N}/u', $password)) + $classes++; + if (preg_match('/[\pP\pS\pZ]/u', $password)) + $classes++; + + if ($classes < $this->config->get('classes')) + throw new BadPassword(sprintf('%s %s', + __('Password does not meet complexity requirements.'), + __('Add upper, lower case letters, number, and symbols') + )); + } + + // Password strength + if ($this->config->get('entropy')) { + // Calculate total possible char count + if (preg_match('/[a-z]/', $password)) + $chars += 26; + if (preg_match('/[A-Z]/', $password)) + $chars += 26; + if (preg_match('/[0-9]/', $password)) + $chars += 10; + if (preg_match('/[!@#$%^&*()]/', $password)) + $chars += 10; + if (preg_match('/ /', $password)) + $chars += 1; + if (preg_match('@[`~_=+[{\]}\\|;:\'",<.>/?-]@', $password)) + $chars += 20; + // High ASCII / UTF-8 + if (preg_match('/[\x80-\xff]/', $password)) + $chars += 128; + + $entropy = strlen($password) * log($chars) / log(2); + + if ($entropy < $this->config->get('entropy')) + throw new BadPassword(sprintf('%s %s %s', + __('Password is not complex enough.'), + __('Try a longer one or use upper case letters, number,and symbols.'), + sprintf(__('Score: %d of %d'), $entropy, + $this->config->get('entropy')) + )); + } + } +} + +class PasswordManagementPlugin +extends Plugin { + var $config_class = 'PasswordManagementConfig'; + + function bootstrap() { + PasswordPolicy::register(new PasswordManagementPolicy($this->getConfig())); + } +} diff --git a/auth-password-policy/plugin.php b/auth-password-policy/plugin.php new file mode 100644 index 00000000..7dfaefb2 --- /dev/null +++ b/auth-password-policy/plugin.php @@ -0,0 +1,13 @@ + 'auth:password-policy', # notrans + 'version' => '0.1', + 'name' => 'Password Management Policies', + 'author' => 'Jared Hancock, Peter Rotich', + 'description' => 'Aggrivate your users with password management policies!', + 'url' => 'http://www.osticket.com/plugins/auth/password-policy', + 'plugin' => 'auth.php:PasswordManagementPlugin' +); + +?> diff --git a/doc/auth-oauth.md b/doc/auth-oauth.md new file mode 100644 index 00000000..21309569 --- /dev/null +++ b/doc/auth-oauth.md @@ -0,0 +1,25 @@ +This OAuth plugin provides SSO sign in from many popular external sources +including Google+, GitHub, Facebook, Windows Azure, and many more. + +**At the current time, only Google+ authentication is implemented.** + +Google+ Authentication +---------------------- +To register for Google+ authentication, + +* Visit the Google Cloud Console (https://console.developers.google.com/) +* Sign in to Google via a relevant account +* Create a project (name it whatever -- osTicket Help Desk) +* Manage the project, navigate to APIs and Auth / Credentials and create an + OAuth Client ID +* Register the key with the URL of your helpdesk plus `api/auth/ext` + (`http://support.mycompany.com/api/auth/ext`). This is called the *Redirect + URI* +* Navigate to APIs & Auth / Consent Screen and fill in the relevant information +* Navigate to APIs & Auth / APIs and add / enable the *Google+ API* +* Install the plugin in osTicket **1.9** +* Configure the plugin with your Google+ Client ID and Secret +* Configure the plugin to authenticate agents (staff), users or both +* Enable the OAuth plugin +* Log out and back in with Google+ +* Enjoy! diff --git a/doc/i18n.md b/doc/i18n.md new file mode 100644 index 00000000..591e6b7f --- /dev/null +++ b/doc/i18n.md @@ -0,0 +1,51 @@ +Making Plugins Translatable +--------------------------- + +The plugin base class has a `translate` static method which is used to +retrieve bootstrapped functions for translations inside the plugin. Use it +to translate strings inside your code: + +```php +class MyPluginsConfig extends PluginConfig { + function getOptions() { + list($__, $_N) = Plugin::translate('my-plugin'); + $__('This string is translatable'); + } +} +``` + +The `translate` method will return two functions (more may be retrieved in +the future), the first is used to translate a single string. The second is +used to translate plural strings. They mimic the `__()` and `_N()` functions +inside the core osTicket code base. + +The $name and other static properties as well as content in the `plugin.php` +file can also be translated. Simply add a comment immediately before the +strings with the content of `trans`, and then translate it when necessary: + +```php +class MyPlugin extends Plugin { + static $name = /* trans */ 'A super-awesome plugin that does stuff'; + + function getName() { + list($__) = self::translate('my-plugin'); + return $__(self::$name); + } +} +``` + +This method overcomes the PHP limitation preventing static properties from +being even remotely dynamic. The PO scanner will recognize the translatable +string by the preceeding `trans` comment. The translated string will be +available in the plugin once translated. + +Compiling PO files +------------------ + +Use the `make-pot` compiler in the osTicket code base to search and compile +the PO file for plugins + + php /path/to/osticket/setup/cli/manage.php i18n make-pot \ + -R auth-plugin \ + -D auth-plugin \ + > auth-plugin.po diff --git a/lib/.keep b/lib/.keep new file mode 100644 index 00000000..e69de29b diff --git a/auth-ldap/include/Net/LDAP2.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2.php similarity index 97% rename from auth-ldap/include/Net/LDAP2.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2.php index d9ecdbc6..14966ef2 100644 --- a/auth-ldap/include/Net/LDAP2.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2.php @@ -13,7 +13,7 @@ * @author Benedikt Hallinger * @copyright 2003-2007 Tarjej Huse, Jan Wagner, Del Elson, Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: LDAP2.php 318473 2011-10-27 13:39:13Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -21,15 +21,15 @@ * Package includes. */ require_once 'PEAR.php'; -require_once 'LDAP2/RootDSE.php'; -require_once 'LDAP2/Schema.php'; -require_once 'LDAP2/Entry.php'; -require_once 'LDAP2/Search.php'; -require_once 'LDAP2/Util.php'; -require_once 'LDAP2/Filter.php'; -require_once 'LDAP2/LDIF.php'; -require_once 'LDAP2/SchemaCache.interface.php'; -require_once 'LDAP2/SimpleFileSchemaCache.php'; +require_once 'Net/LDAP2/RootDSE.php'; +require_once 'Net/LDAP2/Schema.php'; +require_once 'Net/LDAP2/Entry.php'; +require_once 'Net/LDAP2/Search.php'; +require_once 'Net/LDAP2/Util.php'; +require_once 'Net/LDAP2/Filter.php'; +require_once 'Net/LDAP2/LDIF.php'; +require_once 'Net/LDAP2/SchemaCache.interface.php'; +require_once 'Net/LDAP2/SimpleFileSchemaCache.php'; /** * Error constants for errors that are not LDAP errors. @@ -39,7 +39,7 @@ /** * Net_LDAP2 Version */ -define('NET_LDAP2_VERSION', '2.0.10'); +define('NET_LDAP2_VERSION', '2.1.0'); /** * Net_LDAP2 - manipulate LDAP servers the right way! @@ -192,7 +192,7 @@ public static function getVersion() * @access public * @return Net_LDAP2_Error|Net_LDAP2 Net_LDAP2_Error or Net_LDAP2 object */ - public static function &connect($config = array()) + public static function connect($config = array()) { $ldap_check = self::checkLDAPExtension(); if (self::iserror($ldap_check)) { @@ -229,7 +229,7 @@ public static function &connect($config = array()) */ public function __construct($config = array()) { - $this->PEAR('Net_LDAP2_Error'); + parent::__construct('Net_LDAP2_Error'); $this->setConfig($config); } @@ -438,6 +438,17 @@ protected function performConnect() continue; } + // If we're supposed to use TLS, do so before we try to bind, + // as some strict servers only allow binding via secure connections + if ($this->_config["starttls"] === true) { + if (self::isError($msg = $this->startTLS())) { + $current_error = $msg; + $this->_link = false; + $this->_down_host_list[] = $host; + continue; + } + } + // Try to set the configured LDAP version on the connection if LDAP // server needs that before binding (eg OpenLDAP). // This could be necessary since rfc-1777 states that the protocol version @@ -455,6 +466,32 @@ protected function performConnect() $version_set = true; } + // Attempt to bind to the server. If we have credentials configured, + // we try to use them, otherwise its an anonymous bind. + // As stated by RFC-1777, the bind request should be the first + // operation to be performed after the connection is established. + // This may give an protocol error if the server does not support + // V2 binds and the above call to setLDAPVersion() failed. + // In case the above call failed, we try an V2 bind here and set the + // version afterwards (with checking to the rootDSE). + $msg = $this->bind(); + if (self::isError($msg)) { + // The bind failed, discard link and save error msg. + // Then record the host as down and try next one + if ($msg->getCode() == 0x02 && !$version_set) { + // provide a finer grained error message + // if protocol error arieses because of invalid version + $msg = new Net_LDAP2_Error($msg->getMessage(). + " (could not set LDAP protocol version to ". + $this->_config['version'].")", + $msg->getCode()); + } + $this->_link = false; + $current_error = $msg; + $this->_down_host_list[] = $host; + continue; + } + // Set desired LDAP version if not successfully set before. // Here, a check against the rootDSE is performed, so we get a // error message if the server does not support the version. @@ -485,43 +522,6 @@ protected function performConnect() } } - // If we're supposed to use TLS, do so before we try to bind, - // as some strict servers only allow binding via secure connections - if ($this->_config["starttls"] === true) { - if (self::isError($msg = $this->startTLS())) { - $current_error = $msg; - $this->_link = false; - $this->_down_host_list[] = $host; - continue; - } - } - - // Attempt to bind to the server. If we have credentials configured, - // we try to use them, otherwise its an anonymous bind. - // As stated by RFC-1777, the bind request should be the first - // operation to be performed after the connection is established. - // This may give an protocol error if the server does not support - // V2 binds and the above call to setLDAPVersion() failed. - // In case the above call failed, we try an V2 bind here and set the - // version afterwards (with checking to the rootDSE). - $msg = $this->bind(); - if (self::isError($msg)) { - // The bind failed, discard link and save error msg. - // Then record the host as down and try next one - if ($msg->getCode() == 0x02 && !$version_set) { - // provide a finer grained error message - // if protocol error arieses because of invalid version - $msg = new Net_LDAP2_Error($msg->getMessage(). - " (could not set LDAP protocol version to ". - $this->_config['version'].")", - $msg->getCode()); - } - $this->_link = false; - $current_error = $msg; - $this->_down_host_list[] = $host; - continue; - } - // At this stage we have connected, bound, and set up options, // so we have a known good LDAP server. Time to go home. return true; @@ -665,7 +665,7 @@ public function startTLS() public function start_tls() { $args = func_get_args(); - return call_user_func_array(array( &$this, 'startTLS' ), $args); + return call_user_func_array(array( $this, 'startTLS' ), $args); } /** @@ -708,11 +708,11 @@ public function _Net_LDAP2() * This also links the entry to the connection used for the add, * if it was a fresh entry ({@link Net_LDAP2_Entry::createFresh()}) * - * @param Net_LDAP2_Entry &$entry Net_LDAP2_Entry + * @param Net_LDAP2_Entry $entry Net_LDAP2_Entry * * @return Net_LDAP2_Error|true Net_LDAP2_Error object or true */ - public function add(&$entry) + public function add($entry) { if (!$entry instanceof Net_LDAP2_Entry) { return PEAR::raiseError('Parameter to Net_LDAP2::add() must be a Net_LDAP2_Entry object.'); @@ -824,7 +824,7 @@ public function delete($dn, $recursive = false) if ((Net_LDAP2::errorMessage($error_code) === 'LDAP_OPERATIONS_ERROR') && ($this->_config['auto_reconnect'])) { // The server has become disconnected before trying the - // operation. We should try again, possibly with a + // operation. We should try again, possibly with a // different server. $this->_link = false; $this->performReconnect(); @@ -1283,7 +1283,7 @@ public function dnExists($dn) * @return Net_LDAP2_Entry|Net_LDAP2_Error Reference to a Net_LDAP2_Entry object or Net_LDAP2_Error object * @todo Maybe check against the shema should be done to be sure the attribute type exists */ - public function &getEntry($dn, $attr = array()) + public function getEntry($dn, $attr = array()) { if (!is_array($attr)) { $attr = array($attr); @@ -1327,7 +1327,7 @@ public function move($entry, $newdn, $target_ldap = null) if (is_string($entry)) { $entry_o = $this->getEntry($entry); } else { - $entry_o =& $entry; + $entry_o = $entry; } if (!$entry_o instanceof Net_LDAP2_Entry) { return PEAR::raiseError('Parameter $entry is expected to be a Net_LDAP2_Entry object! (If DN was passed, conversion failed)'); @@ -1374,12 +1374,12 @@ public function move($entry, $newdn, $target_ldap = null) * Please note that only attributes you have * selected will be copied. * - * @param Net_LDAP2_Entry &$entry Entry object + * @param Net_LDAP2_Entry $entry Entry object * @param string $newdn New FQF-DN of the entry * * @return Net_LDAP2_Error|Net_LDAP2_Entry Error Message or reference to the copied entry */ - public function ©(&$entry, $newdn) + public function copy($entry, $newdn) { if (!$entry instanceof Net_LDAP2_Entry) { return PEAR::raiseError('Parameter $entry is expected to be a Net_LDAP2_Entry object!'); @@ -1491,7 +1491,7 @@ public static function errorMessage($errorcode) * @access public * @return Net_LDAP2_Error|Net_LDAP2_RootDSE Net_LDAP2_Error or Net_LDAP2_RootDSE object */ - public function &rootDse($attrs = null) + public function rootDse($attrs = null) { if ($attrs !== null && !is_array($attrs)) { return PEAR::raiseError('Parameter $attr is expected to be an array!'); @@ -1502,7 +1502,7 @@ public function &rootDse($attrs = null) // see if we need to fetch a fresh object, or if we already // requested this object with the same attributes if (true || !array_key_exists($attrs_signature, $this->_rootDSE_cache)) { - $rootdse =& Net_LDAP2_RootDSE::fetch($this, $attrs); + $rootdse = Net_LDAP2_RootDSE::fetch($this, $attrs); if ($rootdse instanceof Net_LDAP2_Error) { return $rootdse; } @@ -1520,10 +1520,10 @@ public function &rootDse($attrs = null) * @see rootDse() * @return Net_LDAP2_Error|Net_LDAP2_RootDSE */ - public function &root_dse() + public function root_dse() { $args = func_get_args(); - return call_user_func_array(array(&$this, 'rootDse'), $args); + return call_user_func_array(array($this, 'rootDse'), $args); } /** @@ -1534,7 +1534,7 @@ public function &root_dse() * @access public * @return Net_LDAP2_Schema|Net_LDAP2_Error Net_LDAP2_Schema or Net_LDAP2_Error object */ - public function &schema($dn = null) + public function schema($dn = null) { // Schema caching by Knut-Olav Hoven // If a schema caching object is registered, we use that to fetch @@ -1637,7 +1637,7 @@ public static function checkLDAPExtension() } /** - * Encodes given attributes to UTF8 if needed by schema + * Encodes given attributes from ISO-8859-1 to UTF-8 if needed by schema * * This function takes attributes in an array and then checks against the schema if they need * UTF8 encoding. If that is so, they will be encoded. An encoded array will be returned and @@ -1658,7 +1658,7 @@ public function utf8Encode($attributes) } /** - * Decodes the given attribute values if needed by schema + * Decodes the given attribute values from UTF-8 to ISO-8859-1 if needed by schema * * $attributes is expected to be an array with keys describing * the attribute names and the values as the value of this attribute: @@ -1676,7 +1676,7 @@ public function utf8Decode($attributes) } /** - * Encodes or decodes attribute values if needed + * Encodes or decodes UTF-8/ISO-8859-1 attribute values if needed by schema * * @param array $attributes Array of attributes * @param array $function Function to apply to attribute values @@ -1709,7 +1709,10 @@ protected function utf8($attributes, $function) continue; } - if (false !== strpos($attr['syntax'], '1.3.6.1.4.1.1466.115.121.1.15')) { + // Encoding is needed if this is a DIR_STR. We assume also + // needed encoding in case the schema contains no syntax + // information (he does not need to, see rfc2252, 4.2) + if (!array_key_exists('syntax', $attr) || false !== strpos($attr['syntax'], '1.3.6.1.4.1.1466.115.121.1.15')) { $encode = true; } else { $encode = false; @@ -1743,7 +1746,7 @@ protected function utf8($attributes, $function) * @access public * @return resource LDAP link */ - public function &getLink() + public function getLink() { if ($this->_config['auto_reconnect']) { while (true) { @@ -1789,9 +1792,9 @@ public function __construct($message = 'Net_LDAP2_Error', $code = NET_LDAP2_ERRO $level = E_USER_NOTICE, $debuginfo = null) { if (is_int($code)) { - $this->PEAR_Error($message . ': ' . Net_LDAP2::errorMessage($code), $code, $mode, $level, $debuginfo); + parent::__construct($message . ': ' . Net_LDAP2::errorMessage($code), $code, $mode, $level, $debuginfo); } else { - $this->PEAR_Error("$message: $code", NET_LDAP2_ERROR, $mode, $level, $debuginfo); + parent::__construct("$message: $code", NET_LDAP2_ERROR, $mode, $level, $debuginfo); } } } diff --git a/auth-ldap/include/Net/LDAP2/Entry.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Entry.php similarity index 86% rename from auth-ldap/include/Net/LDAP2/Entry.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Entry.php index 9988a794..bd6544a7 100644 --- a/auth-ldap/include/Net/LDAP2/Entry.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Entry.php @@ -12,7 +12,7 @@ * @author Benedikt Hallinger * @copyright 2009 Tarjej Huse, Jan Wagner, Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: Entry.php 307580 2011-01-19 12:32:05Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -20,7 +20,7 @@ * Includes */ require_once 'PEAR.php'; -require_once 'Util.php'; +require_once 'Net/LDAP2/Util.php'; /** * Object representation of a directory entry @@ -140,28 +140,28 @@ class Net_LDAP2_Entry extends PEAR * You should not call this method manually! Use {@link Net_LDAP2_Entry::createFresh()} * or {@link Net_LDAP2_Entry::createConnected()} instead! * - * @param Net_LDAP2|ressource|array &$ldap Net_LDAP2 object, ldap-link ressource or array of attributes + * @param Net_LDAP2|ressource|array $ldap Net_LDAP2 object, ldap-link ressource or array of attributes * @param string|ressource $entry Either a DN or a LDAP-Entry ressource * * @access protected * @return none */ - protected function __construct(&$ldap, $entry = null) + public function __construct($ldap, $entry = null) { - $this->PEAR('Net_LDAP2_Error'); + parent::__construct('Net_LDAP2_Error'); // set up entry resource or DN - if (is_resource($entry)) { - $this->_entry = &$entry; + if ($entry !== false) { + $this->_entry = $entry; } else { $this->_dn = $entry; } // set up LDAP link if ($ldap instanceof Net_LDAP2) { - $this->_ldap = &$ldap; + $this->_ldap = $ldap; $this->_link = $ldap->getLink(); - } elseif (is_resource($ldap)) { + } elseif ($ldap !== false) { $this->_link = $ldap; } elseif (is_array($ldap)) { // Special case: here $ldap is an array of attributes, @@ -173,7 +173,7 @@ protected function __construct(&$ldap, $entry = null) // if this is an entry existing in the directory, // then set up as old and fetch attrs - if (is_resource($this->_entry) && is_resource($this->_link)) { + if (($this->_entry !== false) && ($this->_link !== false)) { $this->_new = false; $this->_dn = @ldap_get_dn($this->_link, $this->_entry); $this->setAttributes(); // fetch attributes from server @@ -235,7 +235,7 @@ public static function createConnected($ldap, $entry) if (!$ldap instanceof Net_LDAP2) { return PEAR::raiseError("Unable to create connected entry: Parameter \$ldap needs to be a Net_LDAP2 object!"); } - if (!is_resource($entry)) { + if ($entry == false) { return PEAR::raiseError("Unable to create connected entry: Parameter \$entry needs to be a ldap entry resource!"); } @@ -309,7 +309,7 @@ public static function createExisting($dn, $attrs = array()) public function dn($dn = null) { if (false == is_null($dn)) { - if (is_null($this->_dn)) { + if (is_null($this->_dn) ) { $this->_dn = $dn; } else { $this->_newdn = $dn; @@ -354,19 +354,18 @@ protected function setAttributes($attributes = null) /* * fetch attributes from the server */ - if (is_null($attributes) && is_resource($this->_entry) && is_resource($this->_link)) { + if (is_null($attributes) && ($this->_entry !== false) && ($this->_link !== false)) { // fetch schema if ($this->_ldap instanceof Net_LDAP2) { - $schema =& $this->_ldap->schema(); + $schema = $this->_ldap->schema(); } // fetch attributes $attributes = array(); do { if (empty($attr)) { - $ber = null; - $attr = @ldap_first_attribute($this->_link, $this->_entry, $ber); + $attr = @ldap_first_attribute($this->_link, $this->_entry); } else { - $attr = @ldap_next_attribute($this->_link, $this->_entry, $ber); + $attr = @ldap_next_attribute($this->_link, $this->_entry); } if ($attr) { $func = 'ldap_get_values'; // standard function to fetch value @@ -439,13 +438,14 @@ public function getValues() * The first parameter is the name of the attribute * The second parameter influences the way the value is returned: * 'single': only the first value is returned as string - * 'all': all values including the value count are returned in an array + * 'all': all values are returned in an array * 'default': in all other cases an attribute value with a single value is * returned as string, if it has multiple values it is returned - * as an array (without value count) + * as an array * * If the attribute is not set at this entry (no value or not defined in - * schema), an empty string is returned. + * schema), "false" is returned when $option is 'single', an empty string if + * 'default', and an empty array when 'all'. * * You may use Net_LDAP2_Schema->checkAttribute() to see if the attribute * is defined for the objectClasses of this entry. @@ -454,24 +454,42 @@ public function getValues() * @param string $option Option * * @access public - * @return string|array|PEAR_Error string, array or PEAR_Error + * @return string|array */ public function getValue($attr, $option = null) { $attr = $this->getAttrName($attr); - // If the attribute is not set at the entry, return an empty value. - // Users should do schema checks if they want to know if an attribute is - // valid for an entrys OCLs. + // return depending on set $options if (!array_key_exists($attr, $this->_attributes)) { - $value = array(''); - } else { - $value = $this->_attributes[$attr]; - } + // attribute not set + switch ($option) { + case 'single': + $value = false; + break; + case 'all': + $value = array(); + break; + default: + $value = ''; + } - // format the attribute values depending on $option - if ($option == "single" || (count($value) == 1 && $option != 'all')) { - $value = array_shift($value); + } else { + // attribute present + switch ($option) { + case 'single': + $value = $this->_attributes[$attr][0]; + break; + case 'all': + $value = $this->_attributes[$attr]; + break; + default: + $value = $this->_attributes[$attr]; + if (count($value) == 1) { + $value = array_shift($value); + } + } + } return $value; @@ -486,7 +504,7 @@ public function getValue($attr, $option = null) public function get_value() { $args = func_get_args(); - return call_user_func_array(array( &$this, 'getValue' ), $args); + return call_user_func_array(array( $this, 'getValue' ), $args); } /** @@ -541,31 +559,31 @@ public function add($attr = array()) } if ($this->isNew()) { $this->setAttributes($attr); - } else { - foreach ($attr as $k => $v) { - $k = $this->getAttrName($k); - if (false == is_array($v)) { - // Do not add empty values - if ($v == null) { - continue; - } else { - $v = array($v); - } - } - // add new values to existing attribute or add new attribute - if ($this->exists($k)) { - $this->_attributes[$k] = array_unique(array_merge($this->_attributes[$k], $v)); + } + foreach ($attr as $k => $v) { + $k = $this->getAttrName($k); + if (false == is_array($v)) { + // Do not add empty values + if ($v == null) { + continue; } else { - $this->_map[strtolower($k)] = $k; - $this->_attributes[$k] = $v; - } - // save changes for update() - if (empty($this->_changes["add"][$k])) { - $this->_changes["add"][$k] = array(); + $v = array($v); } - $this->_changes["add"][$k] = array_unique(array_merge($this->_changes["add"][$k], $v)); } + // add new values to existing attribute or add new attribute + if ($this->exists($k)) { + $this->_attributes[$k] = array_unique(array_merge($this->_attributes[$k], $v)); + } else { + $this->_map[strtolower($k)] = $k; + $this->_attributes[$k] = $v; + } + // save changes for update() + if (!isset($this->_changes["add"][$k])) { + $this->_changes["add"][$k] = array(); + } + $this->_changes["add"][$k] = array_unique(array_merge($this->_changes["add"][$k], $v)); } + $return = true; return $return; } @@ -742,14 +760,14 @@ public function update($ldap = null) } // ensure we have a valid LDAP object - $ldap =& $this->getLDAP(); + $ldap = $this->getLDAP(); if (!$ldap instanceof Net_LDAP2) { return PEAR::raiseError("The entries LDAP object is not valid"); } // Get and check link $link = $ldap->getLink(); - if (!is_resource($link)) { + if ($link == false) { return PEAR::raiseError("Could not update entry: internal LDAP link is invalid"); } @@ -774,6 +792,14 @@ public function update($ldap = null) $this->_changes['replace'] = array(); $this->_original = $this->_attributes; + // In case the "new" entry was moved after creation, we must + // adjust the internal DNs as the entry was already created + // with the most current DN. + if (false == is_null($this->_newdn)) { + $this->_dn = $this->_newdn; + $this->_newdn = null; + } + $return = true; return $return; } @@ -799,7 +825,8 @@ public function update($ldap = null) $parent = Net_LDAP2_Util::canonical_dn($parent); // rename/move - if (false == @ldap_rename($link, $this->_dn, $child, $parent, true)) { + if (false == @ldap_rename($link, $this->_dn, $child, $parent, false)) { + return PEAR::raiseError("Entry not renamed: " . @ldap_error($link), @ldap_errno($link)); } @@ -809,51 +836,55 @@ public function update($ldap = null) } /* - * Carry out modifications to the entry + * Retrieve a entry that has all attributes we need so that the list of changes to build is created accurately */ + $fullEntry = $ldap->getEntry( $this->dn() ); + if ( Net_LDAP2::isError($fullEntry) ) { + return PEAR::raiseError("Could not retrieve a full set of attributes to reconcile changes with"); + } + $modifications = array(); + // ADD foreach ($this->_changes["add"] as $attr => $value) { - // if attribute exists, add new values - if ($this->exists($attr)) { - if (false === @ldap_mod_add($link, $this->dn(), array($attr => $value))) { - return PEAR::raiseError("Could not add new values to attribute $attr: " . - @ldap_error($link), @ldap_errno($link)); - } - } else { - // new attribute - if (false === @ldap_modify($link, $this->dn(), array($attr => $value))) { - return PEAR::raiseError("Could not add new attribute $attr: " . - @ldap_error($link), @ldap_errno($link)); - } - } - // all went well here, I guess - unset($this->_changes["add"][$attr]); + // if attribute exists, we need to combine old and new values + if ($fullEntry->exists($attr)) { + $currentValue = $fullEntry->getValue($attr, "all"); + $value = array_merge( $currentValue, $value ); + } + + $modifications[$attr] = $value; } // DELETE foreach ($this->_changes["delete"] as $attr => $value) { // In LDAPv3 you need to specify the old values for deleting if (is_null($value) && $ldap->getLDAPVersion() === 3) { - $value = $this->_original[$attr]; + $value = $fullEntry->getValue($attr); } - if (false === @ldap_mod_del($link, $this->dn(), array($attr => $value))) { - return PEAR::raiseError("Could not delete attribute $attr: " . - @ldap_error($link), @ldap_errno($link)); + if (!is_array($value)) { + $value = array($value); } - unset($this->_changes["delete"][$attr]); + + // Find out what is missing from $value and exclude it + $currentValue = isset($modifications[$attr]) ? $modifications[$attr] : $fullEntry->getValue($attr, "all"); + $modifications[$attr] = array_values( array_diff( $currentValue, $value ) ); } // REPLACE foreach ($this->_changes["replace"] as $attr => $value) { - if (false === @ldap_modify($link, $this->dn(), array($attr => $value))) { - return PEAR::raiseError("Could not replace attribute $attr values: " . - @ldap_error($link), @ldap_errno($link)); - } - unset($this->_changes["replace"][$attr]); + $modifications[$attr] = $value; } - // all went well, so _original (server) becomes _attributes (local copy) - $this->_original = $this->_attributes; + // COMMIT + if (false === @ldap_modify($link, $this->dn(), $modifications)) { + return PEAR::raiseError("Could not modify the entry: " . @ldap_error($link), @ldap_errno($link)); + } + + // all went well, so _original (server) becomes _attributes (local copy), reset _changes too... + $this->_changes['add'] = array(); + $this->_changes['delete'] = array(); + $this->_changes['replace'] = array(); + $this->_original = $this->_attributes; $return = true; return $return; @@ -882,7 +913,7 @@ protected function getAttrName($attr) * @access public * @return Net_LDAP2|Net_LDAP2_Error Reference to the Net_LDAP2 Object (the connection) or Net_LDAP2_Error */ - public function &getLDAP() + public function getLDAP() { if (!$this->_ldap instanceof Net_LDAP2) { $err = new PEAR_Error('LDAP is not a valid Net_LDAP2 object'); @@ -898,17 +929,17 @@ public function &getLDAP() * After setting a Net_LDAP2 object, calling update() will use that object for * updating directory contents. Use this to dynamicly switch directorys. * - * @param Net_LDAP2 &$ldap Net_LDAP2 object that this entry should be connected to + * @param Net_LDAP2 $ldap Net_LDAP2 object that this entry should be connected to * * @access public * @return true|Net_LDAP2_Error */ - public function setLDAP(&$ldap) + public function setLDAP($ldap) { if (!$ldap instanceof Net_LDAP2) { return PEAR::raiseError("LDAP is not a valid Net_LDAP2 object"); } else { - $this->_ldap =& $ldap; + $this->_ldap = $ldap; return true; } } @@ -949,7 +980,7 @@ public function markAsNew($mark = true) * * Usage example: * - * $result = $entry->preg_match('/089(\d+)/', 'telephoneNumber', &$matches); + * $result = $entry->preg_match('/089(\d+)/', 'telephoneNumber', $matches); * if ( $result === true ){ * echo "First match: ".$matches[0][1]; // Match of value 1, content of first bracket * } else { @@ -978,11 +1009,6 @@ public function pregMatch($regex, $attr_name, $matches = array()) // fetch attribute values $attr = $this->getValue($attr_name, 'all'); - if (Net_LDAP2::isError($attr)) { - return $attr; - } else { - unset($attr['count']); - } // perform preg_match() on all values $match = false; @@ -1005,7 +1031,7 @@ public function pregMatch($regex, $attr_name, $matches = array()) public function preg_match() { $args = func_get_args(); - return call_user_func_array(array( &$this, 'pregMatch' ), $args); + return call_user_func_array(array( $this, 'pregMatch' ), $args); } /** diff --git a/auth-ldap/include/Net/LDAP2/Filter.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Filter.php similarity index 78% rename from auth-ldap/include/Net/LDAP2/Filter.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Filter.php index d046aa2a..646cf900 100644 --- a/auth-ldap/include/Net/LDAP2/Filter.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Filter.php @@ -10,7 +10,7 @@ * @author Benedikt Hallinger * @copyright 2009 Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: Filter.php 318470 2011-10-27 12:57:05Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -18,7 +18,8 @@ * Includes */ require_once 'PEAR.php'; -require_once 'Util.php'; +require_once 'Net/LDAP2/Util.php'; +require_once 'Net/LDAP2/Entry.php'; /** * Object representation of a part of a LDAP filter. @@ -135,7 +136,7 @@ public function __construct($filter = false) * - approx: One of the attributes values is similar to $value * * Negation ("not") can be done by prepending the above operators with the - * "not" or "!" keyword, see example below. + * "not" or "!" keyword, see example below. * * If $escape is set to true (default) then $value will be escaped * properly. If it is set to false then $value will be treaten as raw filter value string. @@ -160,7 +161,7 @@ public function __construct($filter = false) * * @return Net_LDAP2_Filter|Net_LDAP2_Error */ - public static function &create($attr_name, $match, $value = '', $escape = true) + public static function create($attr_name, $match, $value = '', $escape = true) { $leaf_filter = new Net_LDAP2_Filter(); if ($escape) { @@ -221,7 +222,7 @@ public static function &create($attr_name, $match, $value = '', $escape = true) default: return PEAR::raiseError('Net_LDAP2_Filter create error: matching rule "' . $match . '" not known!'); } - + // negate if requested if ($negate_filter) { $leaf_filter = Net_LDAP2_Filter::combine('!', $leaf_filter); @@ -327,6 +328,17 @@ public static function &combine($log_op, $filters) public static function parse($FILTER) { if (preg_match('/^\((.+?)\)$/', $FILTER, $matches)) { + // Check for right bracket syntax: count of unescaped opening + // brackets must match count of unescaped closing brackets. + // At this stage we may have: + // 1. one filter component with already removed outer brackets + // 2. one or more subfilter components + $c_openbracks = preg_match_all('/(?|<|>=|<=)/', $matches[1], 2, PREG_SPLIT_DELIM_CAPTURE); + $filter_parts = Net_LDAP2_Util::split_attribute_string($matches[1], true, true); if (count($filter_parts) != 3) { return PEAR::raiseError("Filter parsing error: invalid filter syntax - unknown matching rule used"); } else { @@ -543,5 +555,121 @@ protected function isLeaf() return true; // Leaf! } } + + /** + * Filter entries using this filter or see if a filter matches + * + * @todo Currently slow and naive implementation with preg_match, could be optimized (esp. begins, ends filters etc) + * @todo Currently only "="-based matches (equals, begins, ends, contains, any) implemented; Implement all the stuff! + * @todo Implement expert code with schema checks in case $entry is connected to a directory + * @param array|Net_LDAP2_Entry The entry (or array with entries) to check + * @param array If given, the array will be appended with entries who matched the filter. Return value is true if any entry matched. + * @return int|Net_LDAP2_Error Returns the number of matched entries or error + */ + function matches(&$entries, &$results=array()) { + $numOfMatches = 0; + + if (!is_array($entries)) { + $all_entries = array(&$entries); + } else { + $all_entries = &$entries; + } + + foreach ($all_entries as $entry) { + // look at the current entry and see if filter matches + + $entry_matched = false; + // if this is not a single component, do calculate all subfilters, + // then assert the partial results with the given combination modifier + if (!$this->isLeaf()) { + + // get partial results from subfilters + $partial_results = array(); + foreach ($this->_subfilters as $filter) { + $partial_results[] = $filter->matches($entry); + } + + // evaluate partial results using this filters combination rule + switch ($this->_match) { + case '!': + // result is the neagtive result of the assertion + $entry_matched = !$partial_results[0]; + break; + + case '&': + // all partial results have to be boolean-true + $entry_matched = !in_array(false, $partial_results); + break; + + case '|': + // at least one partial result has to be true + $entry_matched = in_array(true, $partial_results); + break; + } + + } else { + // Leaf filter: assert given entry + // [TODO]: Could be optimized to avoid preg_match especially with "ends", "begins" etc + + // Translate the LDAP-match to some preg_match expression and evaluate it + list($attribute, $match, $assertValue) = $this->getComponents(); + switch ($match) { + case '=': + $regexp = '/^'.str_replace('*', '.*', $assertValue).'$/i'; // not case sensitive unless specified by schema + $entry_matched = $entry->pregMatch($regexp, $attribute); + break; + + // ------------------------------------- + // [TODO]: implement <, >, <=, >= and =~ + // ------------------------------------- + + default: + $err = PEAR::raiseError("Net_LDAP2_Filter match error: unsupported match rule '$match'!"); + return $err; + } + + } + + // process filter matching result + if ($entry_matched) { + $numOfMatches++; + $results[] = $entry; + } + + } + + return $numOfMatches; + } + + + /** + * Retrieve this leaf-filters attribute, match and value component. + * + * For leaf filters, this returns array(attr, match, value). + * Match is be the logical operator, not the text representation, + * eg "=" instead of "equals". Note that some operators are really + * a combination of operator+value with wildcard, like + * "begins": That will return "=" with the value "value*"! + * + * For non-leaf filters this will drop an error. + * + * @todo $this->_match is not always available and thus not usable here; it would be great if it would set in the factory methods and constructor. + * @return array|Net_LDAP2_Error + */ + function getComponents() { + if ($this->isLeaf()) { + $raw_filter = preg_replace('/^\(|\)$/', '', $this->_filter); + $parts = Net_LDAP2_Util::split_attribute_string($raw_filter, true, true); + if (count($parts) != 3) { + return PEAR::raiseError("Net_LDAP2_Filter getComponents() error: invalid filter syntax - unknown matching rule used"); + } else { + return $parts; + } + } else { + return PEAR::raiseError('Net_LDAP2_Filter getComponents() call is invalid for non-leaf filters!'); + } + } + + } ?> diff --git a/auth-ldap/include/Net/LDAP2/LDIF.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/LDIF.php similarity index 99% rename from auth-ldap/include/Net/LDAP2/LDIF.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/LDIF.php index 22ed11ef..250e2488 100644 --- a/auth-ldap/include/Net/LDAP2/LDIF.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/LDIF.php @@ -10,7 +10,7 @@ * @author Benedikt Hallinger * @copyright 2009 Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: LDIF.php 302696 2010-08-23 12:48:07Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -18,8 +18,9 @@ * Includes */ require_once 'PEAR.php'; -require_once 'Entry.php'; -require_once 'Util.php'; +require_once 'Net/LDAP2.php'; +require_once 'Net/LDAP2/Entry.php'; +require_once 'Net/LDAP2/Util.php'; /** * LDIF capabilitys for Net_LDAP2, closely taken from PERLs Net::LDAP @@ -217,7 +218,7 @@ class Net_LDAP2_LDIF extends PEAR */ public function __construct($file, $mode = 'r', $options = array()) { - $this->PEAR('Net_LDAP2_Error'); // default error class + parent::__construct('Net_LDAP2_Error'); // default error class // First, parse options // todo: maybe implement further checks on possible values @@ -339,6 +340,7 @@ public function write_entry($entries) + count($entry_attrs_changes['replace']) + count($entry_attrs_changes['delete']); + $is_changed = ($num_of_changes > 0 || $entry->willBeDeleted() || $entry->willBeMoved()); // write version if not done yet diff --git a/auth-ldap/include/Net/LDAP2/RootDSE.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/RootDSE.php similarity index 96% rename from auth-ldap/include/Net/LDAP2/RootDSE.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/RootDSE.php index a66f5697..0693d956 100644 --- a/auth-ldap/include/Net/LDAP2/RootDSE.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/RootDSE.php @@ -10,7 +10,7 @@ * @author Jan Wagner * @copyright 2009 Jan Wagner * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: RootDSE.php 286718 2009-08-03 07:30:49Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -41,7 +41,7 @@ class Net_LDAP2_RootDSE extends PEAR * * @param Net_LDAP2_Entry &$entry Net_LDAP2_Entry object of the RootDSE */ - protected function __construct(&$entry) + public function __construct(&$entry) { $this->_entry = $entry; } @@ -70,7 +70,6 @@ public static function fetch($ldap, $attrs = null) 'altServer', 'supportedExtension', 'supportedControl', - 'supportedCapabilities', # Added for AD detection 'supportedSASLMechanisms', 'supportedLDAPVersion', 'subschemaSubentry' ); diff --git a/auth-ldap/include/Net/LDAP2/Schema.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Schema.php similarity index 98% rename from auth-ldap/include/Net/LDAP2/Schema.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Schema.php index 7eb15662..3b090d39 100644 --- a/auth-ldap/include/Net/LDAP2/Schema.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Schema.php @@ -11,7 +11,7 @@ * @author Benedikt Hallinger * @copyright 2009 Jan Wagner, Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: Schema.php 296515 2010-03-22 14:46:41Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ * @todo see the comment at the end of the file */ @@ -109,9 +109,9 @@ class Net_LDAP2_Schema extends PEAR * * @access protected */ - protected function __construct() + public function __construct() { - $this->PEAR('Net_LDAP2_Error'); // default error class + parent::__construct('Net_LDAP2_Error'); // default error class } /** @@ -123,7 +123,7 @@ protected function __construct() * @access public * @return Net_LDAP2_Schema|NET_LDAP2_Error */ - public function fetch($ldap, $dn = null) + public static function fetch($ldap, $dn = null) { if (!$ldap instanceof Net_LDAP2) { return PEAR::raiseError("Unable to fetch Schema: Parameter \$ldap must be a Net_LDAP2 object!"); diff --git a/auth-ldap/include/Net/LDAP2/SchemaCache.interface.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SchemaCache.interface.php similarity index 95% rename from auth-ldap/include/Net/LDAP2/SchemaCache.interface.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SchemaCache.interface.php index e0c3094c..b5f9ea44 100644 --- a/auth-ldap/include/Net/LDAP2/SchemaCache.interface.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SchemaCache.interface.php @@ -10,7 +10,7 @@ * @author Benedikt Hallinger * @copyright 2009 Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: SchemaCache.interface.php 286718 2009-08-03 07:30:49Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ diff --git a/auth-ldap/include/Net/LDAP2/Search.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Search.php similarity index 92% rename from auth-ldap/include/Net/LDAP2/Search.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Search.php index 90949432..f91681b7 100644 --- a/auth-ldap/include/Net/LDAP2/Search.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Search.php @@ -11,7 +11,7 @@ * @author Benedikt Hallinger * @copyright 2009 Tarjej Huse, Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: Search.php 315417 2011-08-24 12:11:39Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -106,7 +106,7 @@ class Net_LDAP2_Search extends PEAR implements Iterator /** * Cache variable for storing entries fetched internally * - * This currently is only used by {@link pop_entry()} + * This currently is not used by all functions and need consolidation. * * @access protected * @var array @@ -125,20 +125,20 @@ class Net_LDAP2_Search extends PEAR implements Iterator /** * Constructor * - * @param resource &$search Search result identifier - * @param Net_LDAP2|resource &$ldap Net_LDAP2 object or just a LDAP-Link resource + * @param resource $search Search result identifier + * @param Net_LDAP2|resource $ldap Net_LDAP2 object or just a LDAP-Link resource * @param array $attributes (optional) Array with searched attribute names. (see {@link $_searchedAttrs}) * * @access public */ - public function __construct(&$search, &$ldap, $attributes = array()) + public function __construct($search, $ldap, $attributes = array()) { - $this->PEAR('Net_LDAP2_Error'); + parent::__construct('Net_LDAP2_Error'); $this->setSearch($search); if ($ldap instanceof Net_LDAP2) { - $this->_ldap =& $ldap; + $this->_ldap = $ldap; $this->setLink($this->_ldap->getLink()); } else { $this->setLink($ldap); @@ -152,7 +152,7 @@ public function __construct(&$search, &$ldap, $attributes = array()) } /** - * Returns an array of entry objects + * Returns an array of entry objects. * * @return array Array of entry objects. */ @@ -160,15 +160,19 @@ public function entries() { $entries = array(); - while ($entry = $this->shiftEntry()) { - $entries[] = $entry; + if (false === $this->_entry_cache) { + // cache is empty: fetch from LDAP + while ($entry = $this->shiftEntry()) { + $entries[] = $entry; + } + $this->_entry_cache = $entries; // store result in cache } - return $entries; + return $this->_entry_cache; } /** - * Get the next entry in the searchresult. + * Get the next entry in the searchresult from LDAP server. * * This will return a valid Net_LDAP2_Entry object or false, so * you can use this method to easily iterate over the entries inside @@ -176,19 +180,22 @@ public function entries() * * @return Net_LDAP2_Entry|false Reference to Net_LDAP2_Entry object or false */ - public function &shiftEntry() + public function shiftEntry() { if (is_null($this->_entry)) { - $this->_entry = @ldap_first_entry($this->_link, $this->_search); + if(!$this->_entry = @ldap_first_entry($this->_link, $this->_search)) { + $false = false; + return $false; + } $entry = Net_LDAP2_Entry::createConnected($this->_ldap, $this->_entry); - if ($entry instanceof Net_LDAP2_Error) $entry = false; + if ($entry instanceof PEAR_Error) $entry = false; } else { if (!$this->_entry = @ldap_next_entry($this->_link, $this->_entry)) { $false = false; return $false; } $entry = Net_LDAP2_Entry::createConnected($this->_ldap, $this->_entry); - if ($entry instanceof Net_LDAP2_Error) $entry = false; + if ($entry instanceof PEAR_Error) $entry = false; } return $entry; } @@ -202,7 +209,7 @@ public function &shiftEntry() public function shift_entry() { $args = func_get_args(); - return call_user_func_array(array( &$this, 'shiftEntry' ), $args); + return call_user_func_array(array( $this, 'shiftEntry' ), $args); } /** @@ -233,7 +240,7 @@ public function popEntry() public function pop_entry() { $args = func_get_args(); - return call_user_func_array(array( &$this, 'popEntry' ), $args); + return call_user_func_array(array( $this, 'popEntry' ), $args); } /** @@ -431,12 +438,12 @@ public function as_struct() /** * Set the search objects resource link * - * @param resource &$search Search result identifier + * @param resource $search Search result identifier * * @access public * @return void */ - public function setSearch(&$search) + public function setSearch($search) { $this->_search = $search; } @@ -444,12 +451,12 @@ public function setSearch(&$search) /** * Set the ldap ressource link * - * @param resource &$link Link identifier + * @param resource $link Link identifier * * @access public * @return void */ - public function setLink(&$link) + public function setLink($link) { $this->_link = $link; } diff --git a/auth-ldap/include/Net/LDAP2/SimpleFileSchemaCache.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SimpleFileSchemaCache.php similarity index 95% rename from auth-ldap/include/Net/LDAP2/SimpleFileSchemaCache.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SimpleFileSchemaCache.php index 8019654a..e4eae153 100644 --- a/auth-ldap/include/Net/LDAP2/SimpleFileSchemaCache.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/SimpleFileSchemaCache.php @@ -10,7 +10,7 @@ * @author Benedikt Hallinger * @copyright 2009 Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: SimpleFileSchemaCache.php 286718 2009-08-03 07:30:49Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -43,7 +43,7 @@ class Net_LDAP2_SimpleFileSchemaCache implements Net_LDAP2_SchemaCache * * @param array $cfg Config array */ - public function Net_LDAP2_SimpleFileSchemaCache($cfg) + public function __construct($cfg) { foreach ($cfg as $key => $value) { if (array_key_exists($key, $this->config)) { diff --git a/auth-ldap/include/Net/LDAP2/Util.php b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Util.php similarity index 88% rename from auth-ldap/include/Net/LDAP2/Util.php rename to lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Util.php index 48b03f9f..87525e63 100644 --- a/auth-ldap/include/Net/LDAP2/Util.php +++ b/lib/pear-pear.php.net/net_ldap2/Net/LDAP2/Util.php @@ -10,7 +10,7 @@ * @author Benedikt Hallinger * @copyright 2009 Benedikt Hallinger * @license http://www.gnu.org/licenses/lgpl-3.0.txt LGPLv3 -* @version SVN: $Id: Util.php 286718 2009-08-03 07:30:49Z beni $ +* @version SVN: $Id$ * @link http://pear.php.net/package/Net_LDAP2/ */ @@ -113,8 +113,23 @@ public static function ldap_explode_dn($dn, $options = array('casefold' => 'uppe // MV RDN! foreach ($rdns as $subrdn_k => $subrdn_v) { // Casefolding - if ($options['casefold'] == 'upper') $subrdn_v = preg_replace("/^(\w+=)/e", "''.strtoupper('\\1').''", $subrdn_v); - if ($options['casefold'] == 'lower') $subrdn_v = preg_replace("/^(\w+=)/e", "''.strtolower('\\1').''", $subrdn_v); + if ($options['casefold'] == 'upper') { + $subrdn_v = preg_replace_callback( + "/^\w+=/", + function ($matches) { + return strtoupper($matches[0]); + }, + $subrdn_v + ); + } else if ($options['casefold'] == 'lower') { + $subrdn_v = preg_replace_callback( + "/^\w+=/", + function ($matches) { + return strtolower($matches[0]); + }, + $subrdn_v + ); + } if ($options['onlyvalues']) { preg_match('/(.+?)(? 'uppe // normal RDN // Casefolding - if ($options['casefold'] == 'upper') $value = preg_replace("/^(\w+=)/e", "''.strtoupper('\\1').''", $value); - if ($options['casefold'] == 'lower') $value = preg_replace("/^(\w+=)/e", "''.strtolower('\\1').''", $value); + if ($options['casefold'] == 'upper') { + $value = preg_replace_callback( + "/^\w+=/", + function ($matches) { + return strtoupper($matches[0]); + }, + $value + ); + } else if ($options['casefold'] == 'lower') { + $value = preg_replace_callback( + "/^\w+=/", + function ($matches) { + return strtolower($matches[0]); + }, + $value + ); + } if ($options['onlyvalues']) { preg_match('/(.+?)(?, <, >=, <=, ~=). * - * @param string $attr Attribute and Value Syntax + * @param string $attr Attribute and Value Syntax ("foo=bar") + * @param boolean $extended If set to true, also filter-assertion delimeter will be matched + * @param boolean $withDelim If set to true, the return array contains the delimeter at index 1, putting the value to index 2 * - * @return array Indexed array: 0=attribute name, 1=attribute value + * @return array Indexed array: 0=attribute name, 1=attribute value OR ($withDelim=true): 0=attr, 1=delimeter, 2=value */ - public static function split_attribute_string($attr) + public static function split_attribute_string($attr, $extended=false, $withDelim=false) { - return preg_split('/(?=|<=|>|<|~=|=)/', $attr, 2, $withDelim); + } } /** diff --git a/make.php b/make.php new file mode 100644 index 00000000..402dc8d2 --- /dev/null +++ b/make.php @@ -0,0 +1,734 @@ +short, $this->long) = array_slice($options, 0, 2); + $this->help = (isset($options['help'])) ? $options['help'] : ""; + $this->action = (isset($options['action'])) ? $options['action'] + : "store"; + $this->dest = (isset($options['dest'])) ? $options['dest'] + : substr($this->long, 2); + $this->type = (isset($options['type'])) ? $options['type'] + : 'string'; + $this->const = (isset($options['const'])) ? $options['const'] + : null; + $this->default = (isset($options['default'])) ? $options['default'] + : null; + $this->metavar = (isset($options['metavar'])) ? $options['metavar'] + : 'var'; + $this->nargs = (isset($options['nargs'])) ? $options['nargs'] + : 1; + } + + function hasArg() { + return $this->action != 'store_true' + && $this->action != 'store_false'; + } + + function handleValue(&$destination, $args) { + $nargs = 0; + $value = ($this->hasArg()) ? array_shift($args) : null; + if ($value[0] == '-') + $value = null; + elseif ($value) + $nargs = 1; + if ($this->type == 'int') + $value = (int)$value; + switch ($this->action) { + case 'store_true': + $value = true; + break; + case 'store_false': + $value = false; + break; + case 'store_const': + $value = $this->const; + break; + case 'append': + if (!isset($destination[$this->dest])) + $destination[$this->dest] = array($value); + else { + $T = &$destination[$this->dest]; + $T[] = $value; + $value = $T; + } + break; + case 'store': + default: + break; + } + $destination[$this->dest] = $value; + return $nargs; + } + + function toString() { + $short = explode(':', $this->short); + $long = explode(':', $this->long); + if ($this->nargs === '?') + $switches = sprintf(' %s [%3$s], %s[=%3$s]', $short[0], + $long[0], $this->metavar); + elseif ($this->hasArg()) + $switches = sprintf(' %s %3$s, %s=%3$s', $short[0], $long[0], + $this->metavar); + else + $switches = sprintf(" %s, %s", $short[0], $long[0]); + $help = preg_replace('/\s+/', ' ', $this->help); + if (strlen($switches) > 23) + $help = "\n" . str_repeat(" ", 24) . $help; + else + $switches = str_pad($switches, 24); + $help = wordwrap($help, 54, "\n" . str_repeat(" ", 24)); + return $switches . $help; + } +} + +class OutputStream { + var $stream; + + function __construct($stream) { + $this->stream = fopen($stream, 'w'); + } + + function write($what) { + fwrite($this->stream, $what); + } +} + +class Module { + + var $options = array(); + var $arguments = array(); + var $prologue = ""; + var $epilog = ""; + var $usage = '$script [options] $args [arguments]'; + var $autohelp = true; + var $module_name; + + var $stdout; + var $stderr; + + var $_options; + var $_args; + + function __construct() { + $this->options['help'] = array("-h","--help", + 'action'=>'store_true', + 'help'=>"Display this help message"); + foreach ($this->options as &$opt) + $opt = new Option($opt); + $this->stdout = new OutputStream('php://output'); + $this->stderr = new OutputStream('php://stderr'); + } + + function showHelp() { + if ($this->prologue) + echo $this->prologue . "\n\n"; + + global $argv; + $manager = @$argv[0]; + + echo "Usage:\n"; + echo " " . str_replace( + array('$script', '$args'), + array($manager ." ". $this->module_name, implode(' ', array_keys($this->arguments))), + $this->usage) . "\n"; + + ksort($this->options); + if ($this->options) { + echo "\nOptions:\n"; + foreach ($this->options as $name=>$opt) + echo $opt->toString() . "\n"; + } + + if ($this->arguments) { + echo "\nArguments:\n"; + foreach ($this->arguments as $name=>$help) { + $extra = ''; + if (isset($help['options']) && is_array($help['options'])) { + foreach($help['options'] as $op=>$desc) + $extra .= wordwrap( + "\n $op - $desc", 76, "\n "); + } + $help = $help['help']; + echo $name . "\n " . wordwrap( + preg_replace('/\s+/', ' ', $help), 76, "\n ") + .$extra."\n"; + } + } + + if ($this->epilog) { + echo "\n\n"; + $epilog = preg_replace('/\s+/', ' ', $this->epilog); + echo wordwrap($epilog, 76, "\n"); + } + + echo "\n"; + } + + function fail($message, $showhelp=false) { + $this->stderr->write($message . "\n"); + if ($showhelp) + $this->showHelp(); + die(); + } + + function getOption($name, $default=false) { + $this->parseOptions(); + if (isset($this->_options[$name])) + return $this->_options[$name]; + elseif (isset($this->options[$name]) && $this->options[$name]->default) + return $this->options[$name]->default; + else + return $default; + } + + function getArgument($name, $default=false) { + $this->parseOptions(); + if (isset($this->_args[$name])) + return $this->_args[$name]; + return $default; + } + + function parseOptions() { + if (is_array($this->_options)) + return; + + global $argv; + list($this->_options, $this->_args) = + $this->parseArgs(array_slice($argv, 1)); + + foreach (array_keys($this->arguments) as $idx=>$name) { + if (!is_array($this->arguments[$name])) + $this->arguments[$name] = array( + 'help' => $this->arguments[$name]); + $this->arguments[$name]['idx'] = $idx; + } + + foreach ($this->arguments as $name=>$info) { + if (!isset($this->_args[$info['idx']])) { + if (isset($info['required']) && !$info['required']) + continue; + $this->optionError($name . " is a required argument"); + } + else { + $this->_args[$name] = &$this->_args[$info['idx']]; + } + } + + foreach ($this->options as $name=>$opt) + if (!isset($this->_options[$name])) + $this->_options[$name] = $opt->default; + + if ($this->autohelp && $this->getOption('help')) { + $this->showHelp(); + die(); + } + } + + function optionError($error) { + echo "Error: " . $error . "\n\n"; + $this->showHelp(); + die(); + } + + function _run($module_name) { + $this->module_name = $module_name; + $this->parseOptions(); + return $this->run($this->_args, $this->_options); + } + + /* abstract */ + function run($args, $options) { + } + + /* static */ + function register($action, $class) { + global $registered_modules; + $registered_modules[$action] = new $class(); + } + + /* static */ function getInstance($action) { + global $registered_modules; + return $registered_modules[$action]; + } + + function parseArgs($argv) { + $options = $args = array(); + $argv = array_slice($argv, 0); + while ($arg = array_shift($argv)) { + if (strpos($arg, '=') !== false) { + list($arg, $value) = explode('=', $arg, 2); + array_unshift($argv, $value); + } + $found = false; + foreach ($this->options as $opt) { + if ($opt->short == $arg || $opt->long == $arg) { + if ($opt->handleValue($options, $argv)) + array_shift($argv); + $found = true; + } + } + if (!$found && $arg[0] != '-') + $args[] = $arg; + } + return array($options, $args); + } +} + +class PluginBuilder extends Module { + var $prologue = + "Inspects, tests, and builds a plugin PHAR file"; + + var $arguments = array( + 'action' => array( + 'help' => "What to do with the plugin", + 'options' => array( + 'build' => 'Compile a PHAR file for a plugin', + 'hydrate' => 'Prep plugin folders for embedding in osTicket directly', + 'list' => 'List the contents of a phar file', + 'unpack' => 'Unpack a PHAR file (similar to unzip)', + ), + ), + 'plugin' => array( + 'help' => "Plugin to be compiled", + 'required' => false + ), + ); + + var $options = array( + 'sign' => array('-S','--sign', 'metavar'=>'KEY', 'help'=> + 'Sign the compiled PHAR file with the provided OpenSSL private + key file'), + 'verbose' => array('-v','--verbose','help'=> + 'Be more verbose','default'=>false, 'action'=>'store_true'), + 'compress' => array('-z', '--compress', 'help' => + 'Compress source files when hydrading and building. Useful for + saving space when building PHAR files', + 'action'=>'store_true', 'default'=>false), + "key" => array('-k','--key','metavar'=>'API-KEY', + 'help'=>'Crowdin project API key.'), + 'osticket' => array('-R', '--osticket', 'metavar'=>'ROOT', + 'help'=>'Root of osTicket installation (required for language compilation)'), + ); + + static $project = 'osticket-plugins'; + static $crowdin_api_url = 'http://i18n.osticket.com/api/project/{project}/{command}'; + + function run($args, $options) { + $this->key = $options['key']; + if (!$this->key && defined('CROWDIN_API_KEY')) + $this->key = CROWDIN_API_KEY; + + if (@$options['osticket']) { + require $options['osticket'] . '/include/class.translation.php'; + } + + switch (strtolower($args['action'])) { + case 'build': + $plugin = $args['plugin']; + + if (!file_exists($plugin)) + $this->fail("Plugin folder '$plugin' does not exist"); + + $this->_build($plugin, $options); + break; + + case 'hydrate': + $this->_hydrate($options); + break; + case 'list': + $P = new Phar($args[1]); + $base = realpath($args[1]); + foreach (new RecursiveIteratorIterator($P) as $finfo) { + $name = str_replace('phar://'.$base.'/', '', $finfo->getPathname()); + $this->stdout->write($name . "\n"); + } + break; + + case 'list': + $plugin = $args['plugin']; + if (!file_exists($plugin)) + $this->fail("PHAR file '$plugin' does not exist"); + + $p = new Phar($plugin); + $total = 0; + foreach (new RecursiveIteratorIterator($p) as $info) { + $this->stdout->write(sprintf( + "% 10.10d %s %s\n", + $info->getSize(), + strftime('%x %X', $info->getMTime()), + str_replace( + array('phar://', realpath($plugin).'/'), + array('',''), + (string) $info))); + $total += $info->getSize(); + } + $this->stdout->write("---------------------------------------\n"); + $this->stdout->write(sprintf("% 10.10d\n", $total)); + break; + + default: + $this->fail("Unsupported MAKE action. See help"); + } + } + + function _build($plugin, $options) { + @unlink("$plugin.phar"); + $phar = new Phar("$plugin.phar"); + $phar->startBuffering(); + + if ($options['sign']) { + if (!function_exists('openssl_get_privatekey')) + $this->fail('OpenSSL extension required for signing'); + $private = openssl_get_privatekey( + file_get_contents($options['sign'])); + $pkey = ''; + openssl_pkey_export($private, $pkey); + $phar->setSignatureAlgorithm(Phar::OPENSSL, $pkey); + } + + // Read plugin info + $info = (include "$plugin/plugin.php"); + + $this->resolveDependencies(false); + + $phar->buildFromDirectory($plugin); + + // Add library dependencies + if (isset($info['requires'])) { + $includes = array(); + foreach ($info['requires'] as $lib=>$info) { + if (!isset($info['map'])) + continue; + foreach ($info['map'] as $lib=>$local) { + if (preg_match('/{+(.*?)}/', $lib)) + $lib = dirname($lib); + $phar_path = trim($local, '/').'/'; + $full = rtrim(dirname(__file__).'/lib/'.$lib,'/').'/'; + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($full), + RecursiveIteratorIterator::SELF_FIRST); + foreach ($files as $f) { + if (file_exists("$plugin/$phar_path")) + // Hydrated + continue; + elseif ($f->isDir()) + // Unnecessary + continue; + elseif (preg_match('`/tests?/`i', $f->getPathname())) + // Don't package tests + // XXX: Add a option to override this + continue; + $content = ''; + $local = str_replace($full, $phar_path, $f->getPathname()); + if ($options['compress'] && fnmatch('*.php', $f->getPathname())) { + $p = popen('php -w '.realpath($f->getPathname()), 'r'); + while ($b = fread($p, 8192)) + $content .= $b; + fclose($p); + $phar->addFromString($local, $content); + } + else { + $phar->addFile($f->getPathname(), $local); + } + } + } + } + } + + // Add language files + if (@$this->key) { + foreach ($this->getLanguageFiles($plugin) as $name=>$content) { + $name = ltrim($name, '/'); + if (!$content) continue; + $phar->addFromString("i18n/{$name}", $content); + } + } + else { + $this->stderr->write("Specify Crowdin API key to integrate language files\n"); + } + + $phar->setStub('stopBuffering(); + } + + function _hydrate($options) { + $this->resolveDependencies(); + + // Move things into place + foreach (glob(dirname(__file__).'/*/plugin.php') as $plugin) { + $p = (include $plugin); + if ((!isset($p['requires']) || !is_array($p['requires'])) && !isset($p['map'])) + continue; + if (isset($p['requires'])) { + foreach ($p['requires'] as $lib=>$info) { + // Map composer dependencies + if (!isset($info['map']) || !is_array($info['map'])) + continue; + foreach ($info['map'] as $lib=>$local) { + $source = dirname(__file__).'/lib/'.$lib; + $dest = dirname($plugin).'/'.$local; + $this->mapDependencies($options, $lib, $local, $source, $dest); + } + // TODO: Fetch language files for this plugin + } + } + // Map custom dependencies + if (!isset($p['map']) || !is_array($p['map'])) + continue; + foreach ($p['map'] as $lib=>$local) { + $source = dirname(__file__).'/lib/'.$lib; + $dest = dirname($plugin).'/'.$local; + $this->mapDependencies($options, $lib, $local, $source, $dest); + } + } + } + + function mapDependencies($options, $lib, $local, $source, $dest) { + if ($this->options['verbose']) { + $left = str_replace(dirname(__file__).'/', '', $source); + $right = str_replace(dirname(__file__).'/', '', $dest); + $this->stdout->write("Hydrating :: $left => $right\n"); + } + if (is_file($source)) { + copy($left, $right); + return; + } + + // See if we need to do filtering via expandable search + if (preg_match('/{+(.*?)}/', $source)) { + foreach (glob($source, GLOB_BRACE) as $_source) + $this->mapDependencies($options, $lib, $local, $_source, + ($dest.'/'.basename($_source))); + return; + } + + foreach ( + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST) as $item + ) { + if ($item->isDir()) + continue; + + $target = $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathName(); + $parent = dirname($target); + if (!file_exists($parent)) + mkdir($parent, 0777, true); + // Compress PHP files + if ($options['compress'] && fnmatch('*.php', $item)) { + $p = popen('php -w '.realpath($item), 'r'); + $T = fopen($target, 'w'); + while ($b = fread($p, 8192)) + fwrite($T, $b); + fclose($p); + fclose($T); + } + else { + copy($item, $target); + } + } + } + + function _http_get($url) { + #curl post + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_USERAGENT, 'osTicket-cli'); + curl_setopt($ch, CURLOPT_HEADER, FALSE); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, FALSE); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE); + $result=curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return array($code, $result); + } + + function _crowdin($command, $args=array()) { + + $url = str_replace(array('{command}', '{project}'), + array($command, self::$project), + self::$crowdin_api_url); + + $args += array('key' => $this->key); + foreach ($args as &$a) + $a = urlencode($a); + unset($a); + $url .= '?' . http_build_query($args); + + return $this->_http_get($url); + } + + function getTranslations() { + error_reporting(E_ALL); + list($code, $body) = $this->_crowdin('status'); + $langs = array(); + + if ($code != 200) { + $this->stderr->write($code.": Bad status from Crowdin fetching translations\n"); + return $langs; + } + $d = new DOMDocument(); + $d->loadXML($body); + + $xp = new DOMXpath($d); + foreach ($xp->query('//language') as $c) { + $name = $code = ''; + foreach ($c->childNodes as $n) { + switch (strtolower($n->nodeName)) { + case 'name': + $name = $n->textContent; + break; + case 'code': + $code = $n->textContent; + break; + } + } + if (!$code) + continue; + $langs[] = $code; + } + return $langs; + } + + function getLanguageFiles($plugin) { + $files = array(); + if (!class_exists('Translation')) + $this->stderr->write("Specify osTicket root path to compile MO files\n"); + + foreach ($this->getTranslations() as $lang) { + list($code, $stuff) = $this->_crowdin("download/$lang.zip"); + if ($code != 200) { + $this->stderr->write("$lang: Unable to download language files\n"); + continue; + } + + $lang = str_replace('-','_',$lang); + + // Extract a few files from the zip archive + $temp = tempnam('/tmp', 'osticket-cli'); + $f = fopen($temp, 'w'); + fwrite($f, $stuff); + fclose($f); + $zip = new ZipArchive(); + $zip->open($temp); + unlink($temp); + + for ($i=0; $i<$zip->numFiles; $i++) { + $info = $zip->statIndex($i); + if (strpos($info['name'], $plugin) === 0) { + $name = substr($info['name'], strlen($plugin)); + $name = ltrim($name, '/'); + if (substr($name, -3) == '.po' && class_exists('Translation')) { + $content = $this->buildMo($zip->getFromIndex($i)); + $name = substr($name, 0, -3) . '.mo.php'; + } + else { + $content = $zip->getFromIndex($i); + } + // Files in the plugin are laid out by (lang)/(file), + // where (file) has the plugin name removed. Files on + // Crowdin are organized by (plugin)/file + $files["$lang/{$name}"] = $content; + } + } + $zip->close(); + } + return $files; + } + + function buildMo($po_contents) { + $pipes = array(); + $msgfmt = proc_open('msgfmt -o- -', + array(0=>array('pipe','r'), 1=>array('pipe','w')), + $pipes); + if (is_resource($msgfmt)) { + fwrite($pipes[0], $po_contents); + fclose($pipes[0]); + $mo_input = fopen('php://temp', 'r+b'); + fwrite($mo_input, stream_get_contents($pipes[1])); + rewind($mo_input); + $mo = Translation::buildHashFile($mo_input, false, true); + fclose($mo_input); + } + return $mo; + } + + function ensureComposer() { + if (file_exists(dirname(__file__).'/composer.phar')) + return true; + + return static::getComposer(); + } + + function getComposer() { + list($code, $phar) = $this->_http_get('https://getcomposer.org/composer-2.phar'); + + if (!($fp = fopen(dirname(__file__).'/composer.phar', 'wb'))) + $this->fail('Cannot install composer: Unable to write "composer.phar"'); + + fwrite($fp, $phar); + fclose($fp); + } + + function resolveDependencies($autoupdate=true) { + // Build dependency list + $requires = $scripts = $extra = []; + foreach (glob(dirname(__file__).'/*/plugin.php') as $plugin) { + $p = (include $plugin); + if (isset($p['requires'])) + foreach ($p['requires'] as $lib=>$info) + $requires[$lib] = $info['version']; + if (isset($p['scripts'])) + foreach ($p['scripts'] as $name=>$script) + $scripts[$name] = $script; + if (isset($p['extra'])) + foreach ($p['extra'] as $package=>$namespaces) + $extra[$package] = $namespaces; + } + + // Write composer.json file + $composer = <<fail('Unable to save "composer.json"'); + + fwrite($fp, $composer); + fclose($fp); + + $this->ensureComposer(); + + $php = defined('PHP_BINARY') ? PHP_BINARY : 'php'; + if (file_exists(dirname(__file__)."/composer.lock")) { + if ($autoupdate) + passthru($php." ".dirname(__file__)."/composer.phar -v update"); + } + else + passthru($php." ".dirname(__file__)."/composer.phar -v install"); + } +} +$registered_modules = array(); + +if (php_sapi_name() != "cli") + die("Management only supported from command-line\n"); + +$builder = new PluginBuilder(); +$builder->parseOptions(); +$builder->_run(basename(__file__)); + +?> diff --git a/storage-fs/plugin.php b/storage-fs/plugin.php index 5c32f6f1..0eeba5ae 100644 --- a/storage-fs/plugin.php +++ b/storage-fs/plugin.php @@ -1,137 +1,13 @@ meta->getKey(); - $filename = $this->getPath($hash); - if (!$this->fp) - $this->fp = @fopen($filename, 'rb'); - if (!$this->fp) - throw new IOException($filename.': Unable to open for reading'); - if ($offset) - fseek($this->fp, $offset); - if (($status = @fread($this->fp, $bytes)) === false) - throw new IOException($filename.': Unable to read from file'); - return $status; - } - - function passthru() { - $hash = $this->meta->getKey(); - $filename = $this->getPath($hash); - // TODO: Raise IOException on failure - if (($status = @readfile($filename)) === false) - throw new IOException($filename.': Unable to read from file'); - return $status; - } - - function write($data) { - $hash = $this->meta->getKey(); - $filename = $this->getPath($hash); - if (!$this->fp) - $this->fp = @fopen($filename, 'wb'); - if (!$this->fp) - throw new IOException($filename.':Unable to open for reading'); - if (($status = @fwrite($this->fp, $data)) === false) - throw new IOException($filename.': Unable to write to file'); - return $status; - } - - function upload($filepath) { - $destination = $this->getPath($this->meta->getKey()); - if (!@move_uploaded_file($filepath, $destination)) - throw new IOException($filepath.': Unable to move file'); - // TODO: Consider CHMOD on the file - return true; - } - - function unlink() { - $filename = $this->getPath($this->meta->getKey()); - if (!@unlink($filename)) - throw new IOException($filename.': Unable to delete file'); - return true; - } - - function getPath($hash) { - // TODO: Make this configurable - $prefix = $hash[0]; - $base = static::$base; - if ($base[0] != '/' && $base[1] != ':') - $base = ROOT_DIR . $base; - // Auto-create the subfolders - $base .= '/'.$prefix; - if (!is_dir($base)) - mkdir($base, 751); - - return $base.'/'.$hash; - } -} - -class FsStoragePluginConfig extends PluginConfig { - function getOptions() { - return array( - 'uploadpath' => new TextboxField(array( - 'label'=>'Base folder for attachment files', - 'hint'=>'The path must already exist and be writeable by the - web server. If the path starts with neither a `/` or a - drive letter, the path will be assumed to be relative to - the root of osTicket', - 'configuration'=>array('size'=>40), - 'required'=>true, - )), - ); - } - - function pre_save($config, &$errors) { - $path = $config['uploadpath']; - if ($path[0] != '/' && $path[1] != ':') - $path = ROOT_DIR . $path; - - $field = $this->getForm()->getField('uploadpath'); - $file = md5(microtime()); - if (!@is_dir($path)) - $field->addError('Path does not exist'); - elseif (!@opendir($path)) - $field->addError('Unable to access directory'); - elseif (!@touch("$path/$file")) - $field->addError('Unable to write to directory'); - elseif (!@unlink("$path/$file")) - $field->addError('Unable to remove files from directory'); - else - touch("$path/.keep"); - return true; - } -} - -class FsStoragePlugin extends Plugin { - var $config_class = 'FsStoragePluginConfig'; - - function bootstrap() { - $uploadpath = $this->getConfig()->get('uploadpath'); - if ($uploadpath) { - FileStorageBackend::register('F', 'FilesystemStorage'); - FilesystemStorage::$base = $uploadpath; - FilesystemStorage::$desc = 'Filesystem: '.$uploadpath; - } - } -} - return array( 'id' => 'storage:fs', # notrans - 'version' => '0.1', - 'name' => 'Attachments on the filesystem', + 'version' => '0.3', + 'name' => /* trans */ 'Attachments on the filesystem', 'author' => 'Jared Hancock', - 'description' => 'Enables storing attachments on the filesystem', + 'description' => /* trans */ 'Enables storing attachments on the filesystem', 'url' => 'http://www.osticket.com/plugins/storage-fs', - 'plugin' => 'FsStoragePlugin' + 'plugin' => 'storage.php:FsStoragePlugin' ); ?> diff --git a/storage-fs/storage.php b/storage-fs/storage.php new file mode 100644 index 00000000..6ab847c7 --- /dev/null +++ b/storage-fs/storage.php @@ -0,0 +1,146 @@ +meta->getKey(); + $filename = $this->getPath($hash); + if (!$this->fp) + $this->fp = @fopen($filename, 'rb'); + if (!$this->fp) + throw new IOException($filename.': Unable to open for reading'); + if ($offset) + fseek($this->fp, $offset); + if (($status = @fread($this->fp, $bytes)) === false) + throw new IOException($filename.': Unable to read from file'); + return $status; + } + + function passthru() { + $hash = $this->meta->getKey(); + $filename = $this->getPath($hash); + // TODO: Raise IOException on failure + if (($status = @readfile($filename)) === false) + throw new IOException($filename.': Unable to read from file'); + return $status; + } + + function write($data) { + $hash = $this->meta->getKey(); + $filename = $this->getPath($hash); + if (!$this->fp) + $this->fp = @fopen($filename, 'wb'); + if (!$this->fp) + throw new IOException($filename.':Unable to open for reading'); + if (($status = @fwrite($this->fp, $data)) === false) + throw new IOException($filename.': Unable to write to file'); + return $status; + } + + function upload($filepath) { + $destination = $this->getPath($this->meta->getKey()); + if (!@move_uploaded_file($filepath, $destination)) + throw new IOException($filepath.': Unable to move file'); + // TODO: Consider CHMOD on the file + return true; + } + + function unlink() { + $filename = $this->getPath($this->meta->getKey()); + if (!@unlink($filename)) + throw new IOException($filename.': Unable to delete file'); + return true; + } + + function getPath($hash) { + // TODO: Make this configurable + $prefix = $hash[0]; + $base = static::$base; + if ($base[0] != '/' && $base[1] != ':') + $base = ROOT_DIR . $base; + // Auto-create the subfolders + $base .= '/'.$prefix; + if (!is_dir($base)) + mkdir($base, 0751); + + return $base.'/'.$hash; + } +} + +class FsStoragePluginConfig extends PluginConfig { + + // Provide compatibility function for versions of osTicket prior to + // translation support (v1.9.4) + static function translate() { + if (!method_exists('Plugin', 'translate')) { + return array( + function($x) { return $x; }, + function($x, $y, $n) { return $n != 1 ? $y : $x; }, + ); + } + return Plugin::translate('storage-fs'); + } + + function getOptions() { + list($__, $_N) = self::translate(); + return array( + 'uploadpath' => new TextboxField(array( + 'label'=>$__('Base folder for attachment files'), + 'hint'=>$__('The path must already exist and be writeable by the + web server. If the path starts with neither a `/` nor a + drive letter, the path will be assumed to be relative to + the root of osTicket'), + 'configuration'=>array('size'=>60, 'length'=>255), + 'required'=>true, + )), + ); + } + + function pre_save(&$config, &$errors) { + list($__, $_N) = self::translate(); + $path = $config['uploadpath']; + if ($path[0] != '/' && $path[1] != ':') + $path = ROOT_DIR . $path; + + $field = $this->getForm()->getField('uploadpath'); + $file = md5(microtime()); + if (!@is_dir($path)) + $field->addError($__('Path does not exist')); + elseif (!@opendir($path)) + $field->addError($__('Unable to access directory')); + elseif (!@touch("$path/$file")) + $field->addError($__('Unable to write to directory')); + elseif (!@unlink("$path/$file")) + $field->addError($__('Unable to remove files from directory')); + else { + touch("$path/.keep"); + if (!is_file("$path/.htaccess")) + file_put_contents("$path/.htaccess", array('Options -Indexes', PHP_EOL, 'Deny from all')); + } + return true; + } +} + +class FsStoragePlugin extends Plugin { + var $config_class = 'FsStoragePluginConfig'; + + function bootstrap() { + $config = $this->getConfig(); + $uploadpath = $config->get('uploadpath'); + list($__, $_N) = $config::translate(); + if ($uploadpath) { + FileStorageBackend::register('F', 'FilesystemStorage'); + FilesystemStorage::$base = $uploadpath; + FilesystemStorage::$desc = $__('Filesystem') .': '.$uploadpath; + } + } +} + diff --git a/storage-s3/config.php b/storage-s3/config.php new file mode 100644 index 00000000..e655240d --- /dev/null +++ b/storage-s3/config.php @@ -0,0 +1,134 @@ + new TextboxField(array( + 'label' => $__('S3 Bucket'), + 'configuration' => array('size'=>40), + )), + 'folder' => new TextboxField(array( + 'label' => $__('S3 Folder Path'), + 'configuration' => array('size'=>40), + )), + 'aws-region' => new ChoiceField(array( + 'label' => $__('AWS Region'), + 'choices' => array( + '' => 'US Standard', + 'us-east-1' => 'US East (N. Virginia)', + 'us-east-2' => 'US East (Ohio)', + 'us-west-1' => 'US West (N. California)', + 'us-west-2' => 'US West (Oregon)', + 'af-south-1' => 'Africa (Cape Town)', + 'ap-east-1' => 'Asia Pacific (Hong Kong)', + 'ap-south-1' => 'Asia Pacific (Mumbai)', + 'ap-northeast-3' => 'Asia Pacific (Osaka)', + 'ap-northeast-2' => 'Asia Pacific (Seoul)', + 'ap-southeast-1' => 'Asia Pacific (Singapore)', + 'ap-southeast-2' => 'Asia Pacific (Sydney)', + 'ap-northeast-1' => 'Asia Pacific (Tokyo)', + 'ca-central-1' => 'Canada (Central)', + 'cn-north-1' => 'China (Beijing)', + 'cn-northwest-1' => 'China (Ningxia)', + 'eu-central-1' => 'Europe (Frankfurt)', + 'eu-west-1' => 'Europe (Ireland)', + 'eu-west-2' => 'Europe (London)', + 'eu-south-1' => 'Europe (Milan)', + 'eu-west-3' => 'Europe (Paris)', + 'eu-north-1' => 'Europe (Stockholm)', + 'sa-east-1' => 'South America (São Paulo)', + 'me-south-1' => 'Middle East (Bahrain)', + 'us-gov-east-1' => 'AWS GovCloud (US-East)', + 'us-gov-west-1' => 'AWS GovCloud (US-West)', + ), + 'default' => '', + )), + 'acl' => new ChoiceField(array( + 'label' => $__('Default ACL for Attachments'), + 'choices' => array( + '' => $__('Use Bucket Default'), + 'private' => $__('Private'), + 'public-read' => $__('Public Read'), + 'public-read-write' => $__('Public Read and Write'), + 'authenticated-read' => $__('Read for AWS authenticated Users'), + 'bucket-owner-read' => $__('Read for Bucket Owners'), + 'bucket-owner-full-control' => $__('Full Control for Bucket Owners'), + ), + 'default' => '', + )), + + 'access-info' => new SectionBreakField(array( + 'label' => $__('Access Information'), + )), + 'aws-key-id' => new TextboxField(array( + 'required' => true, + 'configuration'=>array('length'=>64, 'size'=>40), + 'label' => $__('AWS Access Key ID'), + )), + 'secret-access-key' => new TextboxField(array( + 'widget' => 'PasswordWidget', + 'required' => false, + 'configuration'=>array('length'=>64, 'size'=>40), + 'label' => $__('AWS Secret Access Key'), + )), + ); + } + + function pre_save(&$config, &$errors) { + list($__, $_N) = self::translate(); + $credentials['credentials'] = array( + 'key' => $config['aws-key-id'], + 'secret' => $config['secret-access-key'] + ?: Crypto::decrypt($this->get('secret-access-key'), SECRET_SALT, + $this->getNamespace()), + ); + if ($config['aws-region']) + $credentials['region'] = $config['aws-region']; + + if (!$credentials['credentials']['secret']) + $this->getForm()->getField('secret-access-key')->addError( + $__('Secret access key is required')); + + $credentials['version'] = '2006-03-01'; + $credentials['signature_version'] = 'v4'; + + $s3 = new Aws\S3\S3Client($credentials); + + try { + $s3->headBucket(array('Bucket'=>$config['bucket'])); + } + catch (Aws\S3\Exception\AccessDeniedException $e) { + $errors['err'] = sprintf( + /* The %s token will become an upstream error message */ + $__('User does not have access to this bucket: %s'), (string)$e); + } + catch (Aws\S3\Exception\NoSuchBucketException $e) { + $this->getForm()->getField('bucket')->addError( + $__('Bucket does not exist')); + } + + if (!$errors && $config['secret-access-key']) + $config['secret-access-key'] = Crypto::encrypt($config['secret-access-key'], + SECRET_SALT, $this->getNamespace()); + else + $config['secret-access-key'] = $this->get('secret-access-key'); + + return true; + } +} diff --git a/storage-s3/plugin.php b/storage-s3/plugin.php new file mode 100644 index 00000000..eb33fa73 --- /dev/null +++ b/storage-s3/plugin.php @@ -0,0 +1,35 @@ + 'storage:s3', + 'version' => '0.5', + 'ost_version' => '1.17', # Require osTicket v1.17+ + 'name' => /* trans */ 'Attachments hosted in Amazon S3', + 'author' => 'Jared Hancock, Kevin Thorne', + 'description' => /* trans */ 'Enables storing attachments in Amazon S3', + 'url' => 'http://www.osticket.com/plugins/storage-s3', + 'requires' => array( + "aws/aws-sdk-php" => array( + 'version' => "3.*", + 'map' => array( + 'aws/aws-sdk-php/src' => 'lib/Aws', + 'guzzlehttp/guzzle/src' => 'lib/GuzzleHttp', + 'guzzlehttp/promises/src' => 'lib/GuzzleHttp/Promise', + 'guzzlehttp/psr7/src/' => 'lib/GuzzleHttp/Psr7', + 'mtdowling/jmespath.php/src' => 'lib/JmesPath', + 'psr/http-client/src' => 'lib/Psr/Http/Client', + 'psr/http-factory/src' => 'lib/Psr/Http/Factory', + 'psr/http-message/src' => 'lib/Psr/Http/Message', + ), + ), + ), + 'scripts' => array( + 'pre-autoload-dump' => 'Aws\\Script\\Composer\\Composer::removeUnusedServices', + ), + 'extra' => array( + 'aws/aws-sdk-php' => ['S3'], + ), + 'plugin' => 'storage.php:S3StoragePlugin' +); + +?> diff --git a/storage-s3/storage.php b/storage-s3/storage.php new file mode 100644 index 00000000..3a717bbf --- /dev/null +++ b/storage-s3/storage.php @@ -0,0 +1,272 @@ +getInfo(); + static::$__config = $config; + } + function getConfig() { + return static::$__config; + } + + function __construct($meta) { + parent::__construct($meta); + $credentials['credentials'] = array( + 'key' => static::$config['aws-key-id'], + 'secret' => Crypto::decrypt(static::$config['secret-access-key'], + SECRET_SALT, $this->getConfig()->getNamespace()) + ); + if (static::$config['aws-region']) + $credentials['region'] = static::$config['aws-region']; + + $credentials['version'] = self::$version; + $credentials['signature_version'] = self::$sig_vers; + + $this->client = new S3Client($credentials); + } + + function read($bytes=false, $offset=0) { + try { + if (!$this->body) + $this->openReadStream(); + // Reads may be cut short to 8k. Try to read $bytes if at all + // possible. + $chunk = ''; + $bytes = $bytes ?: self::getBlockSize(); + while (strlen($chunk) < $bytes) { + $buf = $this->body->read($bytes - strlen($chunk)); + if (!$buf) break; + $chunk .= $buf; + } + return $chunk; + } + catch (Aws\S3\Exception\NoSuchKeyException $e) { + throw new IOException(self::getKey() + .': Unable to locate file: '.(string)$e); + } + } + + function passthru() { + try { + while ($block = $this->read()) + print $block; + } + catch (Aws\S3\Exception\NoSuchKeyException $e) { + throw new IOException(self::getKey() + .': Unable to locate file: '.(string)$e); + } + } + + function write($block) { + if (!$this->body) + $this->openWriteStream(); + if (!isset($this->upload_hash)) + $this->upload_hash = hash_init('md5'); + hash_update($this->upload_hash, $block); + return $this->body->write($block); + } + + function flush() { + return $this->upload($this->body); + } + + function upload($filepath) { + if ($filepath instanceof Stream) { + $filepath->rewind(); + // Hashing already performed in the ::write() method + } + elseif (is_string($filepath)) { + $this->upload_hash = hash_init('md5'); + hash_update_file($this->upload_hash, $filepath); + $filepath = fopen($filepath, 'r'); + rewind($filepath); + } + + try { + $params = array( + 'ContentType' => $this->meta->getType(), + 'CacheControl' => 'private, max-age=86400', + ); + if (isset($this->upload_hash)) + $params['Content-MD5'] = + $this->upload_hash_final = hash_final($this->upload_hash); + + $info = $this->client->upload( + static::$config['bucket'], + self::getKey(true), + $filepath, + static::$config['acl'] ?: 'private', + array('params' => $params) + ); + return true; + } + catch (S3Exception $e) { + throw new IOException('Unable to upload to S3: '.(string)$e); + } + return false; + } + + // Support MD5 hash via the returned ETag header; + function getNativeHashAlgos() { + return array('md5'); + } + + function getHashDigest($algo) { + if ($algo == 'md5' && isset($this->upload_hash_final)) + return $this->upload_hash_final; + + // Return nothing. The migrater will compute the hash by downloading + // the object contents + } + + // Send a redirect when the file is requested locally + function sendRedirectUrl($disposition='inline', $ttl = false) { + // expire based on ttl (if given), otherwise expire at midnight + $now = time(); + $ttl = $ttl ? $now + $ttl : ($now + 86400 - ($now % 86400)); + Http::redirect($this->getSignedRequest( + $this->client->getCommand('GetObject', [ + 'Bucket' => static::$config['bucket'], + 'Key' => self::getKey(), + 'ResponseContentDisposition' => sprintf("%s; %s;", + $disposition, + Http::getDispositionFilename($this->meta->getName())), + ]), $ttl)->getUri()); + return true; + } + + function unlink() { + try { + $this->client->deleteObject(array( + 'Bucket' => static::$config['bucket'], + 'Key' => self::getKey() + )); + return true; + } + catch (S3Exception $e) { + throw new IOException('Unable to remove object: ' + . (string) $e); + } + } + + // Adapted from Aws\S3\StreamWrapper + /** + * Create a pre-signed Request for the given S3 command object. + * + * @param Aws\CommandInterface $command Command to create a pre-signed + * URL for. + * @param int|string|\DateTimeInterface $expires The time at which the URL should + * expire. This can be a Unix + * timestamp, a PHP DateTime object, + * or a string that can be evaluated + * by strtotime(). + * + * @return RequestInterface + */ + protected function getSignedRequest($command, $expires=0) + { + return $this->client->createPresignedRequest($command, $expires ?: '+30 minutes'); + } + + /** + * Initialize the stream wrapper for a read only stream + * + * @return bool + */ + protected function openReadStream() { + $this->getBody(true); + return true; + } + + /** + * Initialize the stream wrapper for a read/write stream + */ + protected function openWriteStream() { + $this->body = new Stream(fopen('php://temp', 'r+')); + } + + protected function getBody($stream=false) { + $params = array( + 'Bucket' => static::$config['bucket'], + 'Key' => self::getKey(), + ); + + $command = $this->client->getCommand('GetObject', $params); + $command['@http']['stream'] = $stream; + $result = $this->client->execute($command); + $this->body = $result['Body']; + + // Wrap the body in a caching entity body if seeking is allowed + //if ($this->getOption('seekable') && !$this->body->isSeekable()) { + // $this->body = new CachingStream($this->body); + //} + return $this->body; + } + + function getKey($create=false) { + $attrs = $create ? self::getAttrs() : $this->meta->getAttrs(); + $attrs = JsonDataParser::parse($attrs); + + $key = ($attrs && $attrs['folder']) ? + sprintf('%s/%s', $attrs['folder'], $this->meta->getKey()) : + $this->meta->getKey(); + + return $key; + } + + function getAttrs() { + $bucket = static::$config['bucket']; + $folder = (static::$config['folder'] ? static::$config['folder'] : ''); + $attr = JsonDataEncoder::encode(array('bucket' => $bucket, 'folder' => $folder)); + + return $attr; + } +} + +require_once 'config.php'; + +class S3StoragePlugin extends Plugin { + var $config_class = 'S3StoragePluginConfig'; + + function isMultiInstance() { + return false; + } + + function bootstrap() { + require_once 'storage.php'; + + //TODO: This needs to target a specific instance + $bucketPath = sprintf('%s%s', $this->getConfig()->get('bucket'), + $this->getConfig()->get('folder') ? '/'. $this->getConfig()->get('folder') : ''); + S3StorageBackend::setConfig($this->getConfig()); + S3StorageBackend::$desc = sprintf('S3 (%s)', $bucketPath); + FileStorageBackend::register('3', 'S3StorageBackend'); + } +} + +require_once INCLUDE_DIR . 'UniversalClassLoader.php'; +use Symfony\Component\ClassLoader\UniversalClassLoader_osTicket; +$loader = new UniversalClassLoader_osTicket(); +$loader->registerNamespaceFallbacks(array( + dirname(__file__).'/lib')); +$loader->register();