| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- <?php
- /**
- * MailHub one-click Webmail login for Roundcube 1.6.x.
- *
- * @license MIT
- */
- class mailhub_sso extends rcube_plugin
- {
- private $attempted = false;
- private $ticket;
- private $credential;
- private $audience;
- public static function info()
- {
- return [
- 'name' => '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;
- }
- }
|