'MailHub Webmail SSO', 'version' => '1.0.0', 'license' => 'MIT', ]; } public function init() { $this->load_config(); $this->add_texts('localization/'); $this->add_hook('startup', [$this, 'startup']); $this->add_hook('authenticate', [$this, 'authenticate']); $this->add_hook('login_after', [$this, 'loginAfter']); $this->add_hook('login_failed', [$this, 'loginFailed']); // logout_after runs after Roundcube has erased the encrypted password. // session_destroy covers logout and expiry while it is still available. $this->add_hook('session_destroy', [$this, 'revokeSession']); } public function startup($args) { if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !array_key_exists('mailhub_ticket', $_POST) ) { return $args; } $this->attempted = true; $ticket = rcube_utils::get_input_string('mailhub_ticket', rcube_utils::INPUT_POST); if ($this->validTicket($ticket)) { $this->ticket = $ticket; } // Roundcube's normal login POST requires its own CSRF token. The // authenticate hook validates and atomically exchanges MailHub's // one-time ticket instead, then marks this request as valid. $args['task'] = 'login'; $args['action'] = 'login'; return $args; } public function authenticate($args) { if (!$this->attempted) { return $args; } $audience = $this->configuredAudience(); $imapHost = $this->configuredImapHost(); if (!$this->ticket || !$audience || !$imapHost) { return $this->authenticationFailure($args); } $result = $this->request('/internal/webmail-sso/exchange', [ 'ticket' => $this->ticket, 'audience' => $audience, ], [200]); $this->ticket = null; if (!$this->validExchange($result)) { $issuedCredential = is_array($result) ? ($result['credential'] ?? null) : null; if ($this->validCredential($issuedCredential)) { $this->revokeCredential($issuedCredential, $audience); } return $this->authenticationFailure($args); } $this->credential = $result['credential']; $this->audience = $audience; // A valid ticket may intentionally switch an existing Roundcube // session to another mailbox. Destroy the old session only after the // ticket has been accepted, so invalid cross-site POSTs cannot log a // user out. if (!empty($_SESSION['user_id'])) { rcmail::get_instance()->kill_session(); } $args['user'] = $result['username']; $args['pass'] = $result['credential']; $args['host'] = $imapHost; $args['cookiecheck'] = false; $args['valid'] = true; $args['abort'] = false; $args['error'] = null; return $args; } public function loginAfter($args) { if (!$this->credential || !$this->audience) { return $args; } // Roundcube already stores the IMAP password encrypted in its session. // Keep only a marker and the non-secret audience for logout revocation. $_SESSION['mailhub_sso_authenticated'] = true; $_SESSION['mailhub_sso_audience'] = $this->audience; return [ '_task' => 'mail', '_mbox' => 'INBOX', ]; } public function loginFailed($args) { if ($this->credential && $this->audience) { $this->revokeCredential($this->credential, $this->audience); $this->credential = null; } return $args; } public function revokeSession($args) { if (empty($_SESSION['mailhub_sso_authenticated'])) { return $args; } $rcmail = rcmail::get_instance(); $credential = $rcmail->get_user_password(); $audience = $_SESSION['mailhub_sso_audience'] ?? null; if ($this->validCredential($credential) && $this->validAudience($audience)) { $this->revokeCredential($credential, $audience); } return $args; } private function authenticationFailure($args) { $this->ticket = null; $args['valid'] = true; $args['abort'] = true; $args['error'] = 'mailhub_sso.ssofailed'; return $args; } private function revokeCredential($credential, $audience) { $this->request('/internal/webmail-sso/revoke', [ 'credential' => $credential, 'audience' => $audience, ], [200, 204]); } private function request($path, $payload, $acceptedStatuses) { $baseUrl = $this->configuredInternalBaseUrl(); $secret = $this->readSecret(); if (!$baseUrl || !$secret || !function_exists('curl_init')) { return null; } $body = json_encode($payload, JSON_UNESCAPED_SLASHES); if (!is_string($body)) { return null; } $curl = curl_init($baseUrl . $path); if (!$curl) { return null; } $options = [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Authorization: Bearer ' . $secret, 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT_MS => 1500, CURLOPT_TIMEOUT_MS => 5000, CURLOPT_NOSIGNAL => true, ]; if (defined('CURLOPT_PROTOCOLS')) { $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS; } curl_setopt_array($curl, $options); $response = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); if (!is_string($response) || !in_array($status, $acceptedStatuses, true)) { return null; } if ($status === 204 || $response === '') { return []; } $decoded = json_decode($response, true); return is_array($decoded) ? $decoded : null; } private function configuredInternalBaseUrl() { $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_internal_base_url', '')); $parts = parse_url($value); if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) || !in_array($parts['path'] ?? '', ['', '/'], true) ) { return null; } return rtrim($value, '/'); } private function configuredAudience() { $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_audience', '')); return $this->validAudience($value) ? $value : null; } private function validAudience($value) { if (!is_string($value)) { return false; } $parts = parse_url($value); return is_array($parts) && strtolower($parts['scheme'] ?? '') === 'https' && !empty($parts['host']) && !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment']) && !isset($parts['path']); } private function configuredImapHost() { $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_imap_host', '')); return preg_match('/\A(?:ssl|tls):\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\z/', $value) ? $value : null; } private function readSecret() { $path = (string) rcmail::get_instance()->config->get('mailhub_sso_secret_file', ''); if ($path === '' || !is_file($path) || !is_readable($path)) { return null; } $secret = trim((string) @file_get_contents($path)); return preg_match('/\A[0-9a-fA-F]{64,512}\z/', $secret) ? $secret : null; } private function validTicket($ticket) { return is_string($ticket) && preg_match('/\Amht_[A-Za-z0-9_-]{28,252}\z/', $ticket); } private function validCredential($credential) { return is_string($credential) && preg_match('/\Amhw_[A-Za-z0-9_-]{28,252}\z/', $credential); } private function validExchange($result) { if (!is_array($result) || !isset($result['username'], $result['credential'], $result['expiresAt']) || !is_string($result['username']) || !is_string($result['expiresAt']) || strlen($result['username']) < 3 || strlen($result['username']) > 320 || preg_match('/[\x00-\x20\x7f]/', $result['username']) || !preg_match('/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/u', $result['username']) || !$this->validCredential($result['credential']) ) { return false; } $expiresAt = strtotime($result['expiresAt']); return $expiresAt !== false && $expiresAt > time() - 30; } }