mailhub_sso.php 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. <?php
  2. /**
  3. * MailHub one-click Webmail login for Roundcube 1.6.x.
  4. *
  5. * @license MIT
  6. */
  7. class mailhub_sso extends rcube_plugin
  8. {
  9. private $attempted = false;
  10. private $ticket;
  11. private $credential;
  12. private $audience;
  13. public static function info()
  14. {
  15. return [
  16. 'name' => 'MailHub Webmail SSO',
  17. 'version' => '1.0.0',
  18. 'license' => 'MIT',
  19. ];
  20. }
  21. public function init()
  22. {
  23. $this->load_config();
  24. $this->add_texts('localization/');
  25. $this->add_hook('startup', [$this, 'startup']);
  26. $this->add_hook('authenticate', [$this, 'authenticate']);
  27. $this->add_hook('login_after', [$this, 'loginAfter']);
  28. $this->add_hook('login_failed', [$this, 'loginFailed']);
  29. // logout_after runs after Roundcube has erased the encrypted password.
  30. // session_destroy covers logout and expiry while it is still available.
  31. $this->add_hook('session_destroy', [$this, 'revokeSession']);
  32. }
  33. public function startup($args)
  34. {
  35. if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
  36. || !array_key_exists('mailhub_ticket', $_POST)
  37. ) {
  38. return $args;
  39. }
  40. $this->attempted = true;
  41. $ticket = rcube_utils::get_input_string('mailhub_ticket', rcube_utils::INPUT_POST);
  42. if ($this->validTicket($ticket)) {
  43. $this->ticket = $ticket;
  44. }
  45. // Roundcube's normal login POST requires its own CSRF token. The
  46. // authenticate hook validates and atomically exchanges MailHub's
  47. // one-time ticket instead, then marks this request as valid.
  48. $args['task'] = 'login';
  49. $args['action'] = 'login';
  50. return $args;
  51. }
  52. public function authenticate($args)
  53. {
  54. if (!$this->attempted) {
  55. return $args;
  56. }
  57. $audience = $this->configuredAudience();
  58. $imapHost = $this->configuredImapHost();
  59. if (!$this->ticket || !$audience || !$imapHost) {
  60. return $this->authenticationFailure($args);
  61. }
  62. $result = $this->request('/internal/webmail-sso/exchange', [
  63. 'ticket' => $this->ticket,
  64. 'audience' => $audience,
  65. ], [200]);
  66. $this->ticket = null;
  67. if (!$this->validExchange($result)) {
  68. $issuedCredential = is_array($result) ? ($result['credential'] ?? null) : null;
  69. if ($this->validCredential($issuedCredential)) {
  70. $this->revokeCredential($issuedCredential, $audience);
  71. }
  72. return $this->authenticationFailure($args);
  73. }
  74. $this->credential = $result['credential'];
  75. $this->audience = $audience;
  76. // A valid ticket may intentionally switch an existing Roundcube
  77. // session to another mailbox. Destroy the old session only after the
  78. // ticket has been accepted, so invalid cross-site POSTs cannot log a
  79. // user out.
  80. if (!empty($_SESSION['user_id'])) {
  81. rcmail::get_instance()->kill_session();
  82. }
  83. $args['user'] = $result['username'];
  84. $args['pass'] = $result['credential'];
  85. $args['host'] = $imapHost;
  86. $args['cookiecheck'] = false;
  87. $args['valid'] = true;
  88. $args['abort'] = false;
  89. $args['error'] = null;
  90. return $args;
  91. }
  92. public function loginAfter($args)
  93. {
  94. if (!$this->credential || !$this->audience) {
  95. return $args;
  96. }
  97. // Roundcube already stores the IMAP password encrypted in its session.
  98. // Keep only a marker and the non-secret audience for logout revocation.
  99. $_SESSION['mailhub_sso_authenticated'] = true;
  100. $_SESSION['mailhub_sso_audience'] = $this->audience;
  101. return [
  102. '_task' => 'mail',
  103. '_mbox' => 'INBOX',
  104. ];
  105. }
  106. public function loginFailed($args)
  107. {
  108. if ($this->credential && $this->audience) {
  109. $this->revokeCredential($this->credential, $this->audience);
  110. $this->credential = null;
  111. }
  112. return $args;
  113. }
  114. public function revokeSession($args)
  115. {
  116. if (empty($_SESSION['mailhub_sso_authenticated'])) {
  117. return $args;
  118. }
  119. $rcmail = rcmail::get_instance();
  120. $credential = $rcmail->get_user_password();
  121. $audience = $_SESSION['mailhub_sso_audience'] ?? null;
  122. if ($this->validCredential($credential) && $this->validAudience($audience)) {
  123. $this->revokeCredential($credential, $audience);
  124. }
  125. return $args;
  126. }
  127. private function authenticationFailure($args)
  128. {
  129. $this->ticket = null;
  130. $args['valid'] = true;
  131. $args['abort'] = true;
  132. $args['error'] = 'mailhub_sso.ssofailed';
  133. return $args;
  134. }
  135. private function revokeCredential($credential, $audience)
  136. {
  137. $this->request('/internal/webmail-sso/revoke', [
  138. 'credential' => $credential,
  139. 'audience' => $audience,
  140. ], [200, 204]);
  141. }
  142. private function request($path, $payload, $acceptedStatuses)
  143. {
  144. $baseUrl = $this->configuredInternalBaseUrl();
  145. $secret = $this->readSecret();
  146. if (!$baseUrl || !$secret || !function_exists('curl_init')) {
  147. return null;
  148. }
  149. $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
  150. if (!is_string($body)) {
  151. return null;
  152. }
  153. $curl = curl_init($baseUrl . $path);
  154. if (!$curl) {
  155. return null;
  156. }
  157. $options = [
  158. CURLOPT_POST => true,
  159. CURLOPT_POSTFIELDS => $body,
  160. CURLOPT_HTTPHEADER => [
  161. 'Accept: application/json',
  162. 'Authorization: Bearer ' . $secret,
  163. 'Content-Type: application/json',
  164. ],
  165. CURLOPT_RETURNTRANSFER => true,
  166. CURLOPT_HEADER => false,
  167. CURLOPT_FOLLOWLOCATION => false,
  168. CURLOPT_CONNECTTIMEOUT_MS => 1500,
  169. CURLOPT_TIMEOUT_MS => 5000,
  170. CURLOPT_NOSIGNAL => true,
  171. ];
  172. if (defined('CURLOPT_PROTOCOLS')) {
  173. $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
  174. }
  175. curl_setopt_array($curl, $options);
  176. $response = curl_exec($curl);
  177. $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
  178. curl_close($curl);
  179. if (!is_string($response) || !in_array($status, $acceptedStatuses, true)) {
  180. return null;
  181. }
  182. if ($status === 204 || $response === '') {
  183. return [];
  184. }
  185. $decoded = json_decode($response, true);
  186. return is_array($decoded) ? $decoded : null;
  187. }
  188. private function configuredInternalBaseUrl()
  189. {
  190. $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_internal_base_url', ''));
  191. $parts = parse_url($value);
  192. if (!is_array($parts)
  193. || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
  194. || empty($parts['host'])
  195. || isset($parts['user'])
  196. || isset($parts['pass'])
  197. || isset($parts['query'])
  198. || isset($parts['fragment'])
  199. || !in_array($parts['path'] ?? '', ['', '/'], true)
  200. ) {
  201. return null;
  202. }
  203. return rtrim($value, '/');
  204. }
  205. private function configuredAudience()
  206. {
  207. $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_audience', ''));
  208. return $this->validAudience($value) ? $value : null;
  209. }
  210. private function validAudience($value)
  211. {
  212. if (!is_string($value)) {
  213. return false;
  214. }
  215. $parts = parse_url($value);
  216. return is_array($parts)
  217. && strtolower($parts['scheme'] ?? '') === 'https'
  218. && !empty($parts['host'])
  219. && !isset($parts['user'])
  220. && !isset($parts['pass'])
  221. && !isset($parts['query'])
  222. && !isset($parts['fragment'])
  223. && !isset($parts['path']);
  224. }
  225. private function configuredImapHost()
  226. {
  227. $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_imap_host', ''));
  228. return preg_match('/\A(?:ssl|tls):\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\z/', $value) ? $value : null;
  229. }
  230. private function readSecret()
  231. {
  232. $path = (string) rcmail::get_instance()->config->get('mailhub_sso_secret_file', '');
  233. if ($path === '' || !is_file($path) || !is_readable($path)) {
  234. return null;
  235. }
  236. $secret = trim((string) @file_get_contents($path));
  237. return preg_match('/\A[0-9a-fA-F]{64,512}\z/', $secret) ? $secret : null;
  238. }
  239. private function validTicket($ticket)
  240. {
  241. return is_string($ticket)
  242. && preg_match('/\Amht_[A-Za-z0-9_-]{28,252}\z/', $ticket);
  243. }
  244. private function validCredential($credential)
  245. {
  246. return is_string($credential)
  247. && preg_match('/\Amhw_[A-Za-z0-9_-]{28,252}\z/', $credential);
  248. }
  249. private function validExchange($result)
  250. {
  251. if (!is_array($result)
  252. || !isset($result['username'], $result['credential'], $result['expiresAt'])
  253. || !is_string($result['username'])
  254. || !is_string($result['expiresAt'])
  255. || strlen($result['username']) < 3
  256. || strlen($result['username']) > 320
  257. || preg_match('/[\x00-\x20\x7f]/', $result['username'])
  258. || !preg_match('/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/u', $result['username'])
  259. || !$this->validCredential($result['credential'])
  260. ) {
  261. return false;
  262. }
  263. $expiresAt = strtotime($result['expiresAt']);
  264. return $expiresAt !== false && $expiresAt > time() - 30;
  265. }
  266. }