diff --git a/README.md b/README.md index ef83b10..c5f4dbe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Roundcube Userli Plugin -A Roundcube plugin that automatically synchronizes user identities from a userli aliases API at login. +A Roundcube plugin for [Userli](https://github.com/systemli/userli): + +* **Login with two-factor authentication:** the plugin checks the password and, for users with 2FA, asks for the TOTP or backup code in a second step. Userli then issues a webmail credential, which Roundcube uses for IMAP and SMTP instead of the password. It is valid for 12 hours; the Roundcube session ends with it. Logging out revokes it. With a Userli version without the webmail login endpoint, the plugin keeps logging in with the password. + + ![Two-factor code step](screenshots/twofactor.png) +* **Alias identities:** at login, the plugin creates a Roundcube identity for each alias of the user. + + ![Alias identities](screenshots/aliases.png) ## Installation diff --git a/composer.json b/composer.json index 9d4ca6f..ccbecec 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "extra": { "roundcube": { - "min-version": "1.4.0" + "min-version": "1.6.0" } }, "config": { diff --git a/composer.lock b/composer.lock index 5560730..448503a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3c8f7412c2582f69a613f2162b371ccf", + "content-hash": "bed9c5de82682eb1aa599fa3b36afce8", "packages": [ { "name": "roundcube/plugin-installer", @@ -1871,5 +1871,5 @@ "php": "^8.1" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/localization/de_DE.inc b/localization/de_DE.inc new file mode 100644 index 0000000..a1a2aae --- /dev/null +++ b/localization/de_DE.inc @@ -0,0 +1,13 @@ +assertArrayHasKey('login_after', $hooks); } - public function testAuthenticateStoresPassword(): void - { - $args = ['pass' => 'test_password_123']; - - $this->plugin->authenticate_store_pass($args); - - // We can't directly test the private property, but we can verify no errors occur - $this->assertTrue(true); - } - public function testLoginAfterWithSuccessfulApiResponse(): void { // Mock configuration diff --git a/tests/UserliLoginTest.php b/tests/UserliLoginTest.php new file mode 100644 index 0000000..39aa1da --- /dev/null +++ b/tests/UserliLoginTest.php @@ -0,0 +1,381 @@ +status; + } + + public function getBody(): string + { + return json_encode($this->body); + } +} + +class FakeClient +{ + public array $calls = []; + + /** @param array $responses */ + public function __construct(private array $responses) + { + } + + public function request(string $method, string $url, array $options) + { + $this->calls[] = ['url' => $url, 'json' => $options['json'], 'http_errors' => $options['http_errors']]; + $response = array_shift($this->responses); + if ($response instanceof Exception) { + throw $response; + } + return $response; + } +} + +class UserliLoginTest extends TestCase +{ + private userli $plugin; + private rcmail $rcmail; + + protected function setUp(): void + { + $_SESSION = []; + $_POST = []; + $_GET = []; + + $this->plugin = new userli(); + $this->rcmail = rcmail::get_instance(); + $this->rcmail->sessionKilled = false; + + $config = $this->getMockBuilder(stdClass::class)->addMethods(['get'])->getMock(); + $config->method('get')->willReturnCallback(static fn ($key) => [ + 'userli_url' => 'https://users.example.org', + 'userli_ssl_verify' => true, + 'userli_token' => 'token', + ][$key] ?? null); + $this->rcmail->config = $config; + } + + private function client(FakeResponse|Exception ...$responses): FakeClient + { + $client = new FakeClient($responses); + $this->rcmail->set_http_client($client); + return $client; + } + + private function login(string $user, string $pass): array + { + return $this->plugin->authenticate(['user' => $user, 'pass' => $pass, 'valid' => true, 'abort' => false, 'error' => null]); + } + + public function testInitRegistersLoginHooks(): void + { + $this->plugin->init(); + + $this->assertArrayHasKey('startup', $this->plugin->getHooks()); + $this->assertArrayHasKey('loginform_content', $this->plugin->getHooks()); + $this->assertArrayHasKey('login_failed', $this->plugin->getHooks()); + $this->assertArrayHasKey('session_destroy', $this->plugin->getHooks()); + } + + public function testPasswordIsExchangedForWebmailCredential(): void + { + $client = $this->client(new FakeResponse(200, ['password' => 'credential', 'expiresAt' => '2030-01-01T00:00:00+00:00'])); + + $args = $this->login('user@example.org', 'password'); + + $this->assertSame('credential', $args['pass']); + $this->assertFalse($args['abort']); + $this->assertSame('https://users.example.org/api/roundcube/login', $client->calls[0]['url']); + $this->assertSame(['email' => 'user@example.org', 'password' => 'password', 'code' => ''], $client->calls[0]['json']); + $this->assertFalse($client->calls[0]['http_errors']); + } + + public function testExpiryIsStoredAndAliasesUseThePassword(): void + { + $client = $this->client( + new FakeResponse(200, ['password' => 'credential', 'expiresAt' => '2030-01-01T00:00:00+00:00']), + new FakeResponse(200, []), + ); + $user = $this->getMockBuilder(stdClass::class)->addMethods(['get_username', 'list_emails'])->getMock(); + $user->method('get_username')->willReturn('user@example.org'); + $user->method('list_emails')->willReturn([]); + $this->rcmail->user = $user; + + $this->login('user@example.org', 'password'); + $this->plugin->login_after_update_identities(); + + $this->assertSame(strtotime('2030-01-01T00:00:00+00:00'), $_SESSION['userli_expires']); + $this->assertSame('https://users.example.org/api/roundcube/aliases', $client->calls[1]['url']); + $this->assertSame('password', $client->calls[1]['json']['password']); + } + + public function testOldUserliWithoutLoginEndpointKeepsPassword(): void + { + $this->client(new FakeResponse(404)); + + $args = $this->login('user@example.org', 'password'); + + $this->assertSame('password', $args['pass']); + $this->assertFalse($args['abort']); + } + + public function testWrongPasswordFailsLikeBefore(): void + { + $this->client(new FakeResponse(401, ['message' => 'authentication failed'])); + + $args = $this->login('user@example.org', 'wrong'); + + $this->assertTrue($args['abort']); + $this->assertNull($args['error']); + } + + public function testUnreachableUserliAbortsLogin(): void + { + $this->client(new Exception('Connection refused')); + + $args = $this->login('user@example.org', 'password'); + + $this->assertTrue($args['abort']); + $this->assertSame('userli.login_unavailable', $args['error']); + } + + public function testTwofactorRequiredShowsCodeForm(): void + { + $this->client(new FakeResponse(401, ['message' => 'twofactor required'])); + + $args = $this->login('totp@example.org', 'password'); + $form = $this->plugin->modify_login_form(['hidden' => [], 'inputs' => ['user' => [], 'password' => []], 'buttons' => []]); + + $this->assertTrue($args['abort']); + $this->assertSame('userli.twofactor_required', $args['error']); + $this->assertSame(['code'], array_keys($form['inputs'])); + $this->assertStringContainsString('name="_userli_code"', $form['inputs']['code']['content']); + $this->assertStringContainsString('value="totp@example.org"', $form['hidden']['user']); + $this->assertSame('totp@example.org', $_SESSION['userli_2fa']['user']); + $this->assertNotSame('password', $_SESSION['userli_2fa']['pass']); + } + + public function testCodeStepSendsPendingPasswordAndCode(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time()]; + $_POST['_userli_code'] = '123456'; + $client = $this->client(new FakeResponse(200, ['password' => 'credential'])); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('totp@example.org', ''); + + $this->assertSame('credential', $args['pass']); + $this->assertSame(['email' => 'totp@example.org', 'password' => 'password', 'code' => '123456'], $client->calls[0]['json']); + $this->assertArrayNotHasKey('userli_2fa', $_SESSION); + } + + public function testWrongCodeKeepsCodeForm(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time()]; + $_POST['_userli_code'] = '000000'; + $this->client(new FakeResponse(401, ['message' => 'authentication failed'])); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('totp@example.org', ''); + $this->plugin->modify_login_form(['hidden' => [], 'inputs' => [], 'buttons' => []]); + + $this->assertSame('userli.twofactor_invalid', $args['error']); + $this->assertSame('totp@example.org', $_SESSION['userli_2fa']['user']); + } + + public function testCodeFormOffersAnotherAccount(): void + { + $this->client(new FakeResponse(401, ['message' => 'twofactor required'])); + + $this->login('totp@example.org', 'password'); + $form = $this->plugin->modify_login_form(['hidden' => [], 'inputs' => [], 'buttons' => []]); + + $this->assertStringContainsString('_userli_cancel=1', $form['buttons']['userli_cancel']['content']); + } + + public function testCancelDropsPendingLogin(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time()]; + $_GET['_userli_cancel'] = '1'; + + $this->plugin->startup(['task' => 'login', 'action' => '']); + $form = $this->plugin->modify_login_form(['hidden' => [], 'inputs' => ['password' => []], 'buttons' => []]); + + $this->assertArrayHasKey('password', $form['inputs']); + $this->assertArrayNotHasKey('userli_2fa', $_SESSION); + } + + public function testCodeStepIsNotAFailedLogin(): void + { + $this->client(new FakeResponse(401, ['message' => 'twofactor required'])); + + $this->login('totp@example.org', 'password'); + + $this->assertSame(200, $this->plugin->unauthenticated(['task' => 'login', 'http_code' => 401])['http_code']); + } + + public function testWrongCodeStaysAFailedLogin(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time()]; + $_POST['_userli_code'] = '000000'; + $this->client(new FakeResponse(401, ['message' => 'authentication failed'])); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $this->login('totp@example.org', ''); + + $this->assertSame(401, $this->plugin->unauthenticated(['task' => 'login', 'http_code' => 401])['http_code']); + } + + public function testInvalidRequestIsNotSentToUserli(): void + { + $client = $this->client(); + + $args = $this->plugin->authenticate(['user' => 'user@example.org', 'pass' => 'password', 'valid' => false, 'abort' => false, 'error' => null]); + + $this->assertSame([], $client->calls); + $this->assertSame('password', $args['pass']); + } + + public function testExpiredPendingLoginEndsCodeStep(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time() - 301]; + $_POST['_userli_code'] = '123456'; + $client = $this->client(); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('totp@example.org', ''); + $form = $this->plugin->modify_login_form(['hidden' => [], 'inputs' => ['password' => []], 'buttons' => []]); + + $this->assertSame([], $client->calls); + $this->assertTrue($args['abort']); + $this->assertSame('userli.twofactor_expired', $args['error']); + $this->assertArrayHasKey('password', $form['inputs']); + $this->assertArrayNotHasKey('userli_2fa', $_SESSION); + } + + public function testPendingLoginOfAnotherUserIsNotUsed(): void + { + $_SESSION['userli_2fa'] = ['user' => 'totp@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time()]; + $_POST['_userli_code'] = '123456'; + $client = $this->client(); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('other@example.org', ''); + + $this->assertSame([], $client->calls); + $this->assertSame('userli.twofactor_expired', $args['error']); + } + + public function testTooManyAttempts(): void + { + $this->client(new FakeResponse(429, ['message' => 'too many attempts'])); + + $args = $this->login('user@example.org', 'password'); + + $this->assertTrue($args['abort']); + $this->assertSame('userli.too_many_attempts', $args['error']); + } + + public function testPasswordChangeRequired(): void + { + $this->client(new FakeResponse(403, ['message' => 'user password change required'])); + + $args = $this->login('user@example.org', 'password'); + + $this->assertSame('userli.password_change_required', $args['error']); + } + + public function testDisabledUserFailsLikeWrongPassword(): void + { + $this->client(new FakeResponse(403, ['message' => 'user disabled due to spam role'])); + + $args = $this->login('spam@example.org', 'password'); + + $this->assertTrue($args['abort']); + $this->assertNull($args['error']); + } + + public function testUnexpectedCredentialFormatAbortsLogin(): void + { + $this->client(new FakeResponse(200, ['password' => ['not', 'a', 'string'], 'expiresAt' => []])); + + $args = $this->login('user@example.org', 'password'); + + $this->assertTrue($args['abort']); + $this->assertSame('userli.login_unavailable', $args['error']); + } + + public function testFailedImapLoginRevokesCredential(): void + { + $client = $this->client(new FakeResponse(200, ['password' => 'credential']), new FakeResponse(204)); + + $this->login('user@example.org', 'password'); + $this->plugin->login_failed(['user' => 'user@example.org', 'code' => 1]); + + $this->assertSame('https://users.example.org/api/roundcube/logout', $client->calls[1]['url']); + $this->assertSame(['email' => 'user@example.org', 'password' => 'credential'], $client->calls[1]['json']); + } + + public function testFailedLoginWithoutCredentialRevokesNothing(): void + { + $client = $this->client(new FakeResponse(401, ['message' => 'authentication failed'])); + + $this->login('user@example.org', 'wrong'); + $this->plugin->login_failed(['user' => 'user@example.org', 'code' => 1]); + + $this->assertCount(1, $client->calls); + } + + public function testSessionDestroyRevokesWebmailCredential(): void + { + $_SESSION = ['user_id' => 1, 'username' => 'user@example.org', 'password' => $this->rcmail->encrypt('credential')]; + $client = $this->client(new FakeResponse(204)); + + $this->plugin->session_destroy([]); + + $this->assertSame('https://users.example.org/api/roundcube/logout', $client->calls[0]['url']); + $this->assertSame(['email' => 'user@example.org', 'password' => 'credential'], $client->calls[0]['json']); + } + + public function testSessionDestroyWithoutLoginRevokesNothing(): void + { + $_SESSION = ['temp' => true]; + $client = $this->client(); + + $this->plugin->session_destroy([]); + + $this->assertSame([], $client->calls); + } + + public function testSessionEndsWhenCredentialExpired(): void + { + $_SESSION = ['user_id' => 1, 'userli_expires' => time() - 1]; + + $this->plugin->startup(['task' => 'mail', 'action' => '']); + + $this->assertTrue($this->rcmail->sessionKilled); + } + + public function testSessionContinuesBeforeCredentialExpired(): void + { + $_SESSION = ['user_id' => 1, 'userli_expires' => time() + 60]; + + $this->plugin->startup(['task' => 'mail', 'action' => '']); + + $this->assertFalse($this->rcmail->sessionKilled); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4624306..6421256 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,8 +11,28 @@ class rcube_plugin { public $task; + public $api; private $hooks = []; + public function __construct() + { + $this->api = new stdClass(); + $this->api->output = new class { + public function add_script($script) + { + } + }; + } + + protected function add_texts($dir) + { + } + + public function gettext($name) + { + return $name; + } + protected function add_hook($hook, $callback) { $this->hooks[$hook] = $callback; @@ -36,8 +56,46 @@ class rcmail private static $instance; public $user; public $config; + public $sessionKilled = false; + public $session; private $httpClient; + public function __construct() + { + $this->session = new class { + public function remove($var) + { + unset($_SESSION[$var]); + } + }; + } + + public function encrypt($clear) + { + return 'encrypted:' . base64_encode($clear); + } + + public function decrypt($cipher) + { + return base64_decode(substr($cipher, strlen('encrypted:'))); + } + + public function get_user_name() + { + return $_SESSION['username'] ?? null; + } + + public function get_user_password() + { + return isset($_SESSION['password']) ? $this->decrypt($_SESSION['password']) : null; + } + + public function kill_session() + { + $this->sessionKilled = true; + $_SESSION = ['language' => 'en_US', 'temp' => true]; + } + public static function get_instance() { if (!self::$instance) { @@ -65,5 +123,62 @@ public static function raise_error($args, $log = false, $fatal = false) { // Mock implementation - do nothing in tests } + + public static function write_log($name, $line) + { + } + } +} + +if (!class_exists('rcube_utils')) { + class rcube_utils + { + public const INPUT_GET = 1; + public const INPUT_POST = 2; + + public static function get_input_string($name, $source) + { + return ($source === self::INPUT_GET ? $_GET[$name] ?? '' : $_POST[$name] ?? ''); + } + } +} + +if (!class_exists('html')) { + class html + { + public static function label($for, $text) + { + return ''; + } + + public static function a($attrib, $content) + { + return '' . $content . ''; + } + + public static function quote($str) + { + return htmlspecialchars((string) $str); + } + } + + class html_inputfield + { + public function __construct(private array $attrib = []) + { + } + + public function show() + { + $attrs = ''; + foreach ($this->attrib as $key => $value) { + $attrs .= ' ' . $key . '="' . htmlspecialchars((string) $value) . '"'; + } + return ''; + } + } + + class html_hiddenfield extends html_inputfield + { } } diff --git a/userli.php b/userli.php index b39c97e..d59ba0f 100644 --- a/userli.php +++ b/userli.php @@ -1,11 +1,12 @@ add_texts("localization/"); + // Before the requested action runs, also before the login action resets the session + $this->add_hook("startup", [$this, "startup"]); // Alter loginform content $this->add_hook("loginform_content", [$this, "modify_login_form"]); // Before the user login on the IMAP server is performed. - $this->add_hook("authenticate", [$this, "authenticate_store_pass"]); + $this->add_hook("authenticate", [$this, "authenticate"]); // Triggered after a user successfully logged in $this->add_hook("login_after", [ $this, "login_after_update_identities", ]); + // IMAP login failed after userli issued a credential + $this->add_hook("login_failed", [$this, "login_failed"]); + // Logout, session timeout or a new login, after Roundcube's own logout actions + $this->add_hook("session_destroy", [$this, "session_destroy"]); + // Before the login page is sent, decides between HTTP 401 and 200 + $this->add_hook("unauthenticated", [$this, "unauthenticated"]); + } + + public function startup(array $args): array + { + $rcmail = rcmail::get_instance(); + + if ($args["task"] === "login") { + $pending = $_SESSION["userli_2fa"] ?? null; + $rcmail->session->remove("userli_2fa"); + $valid = is_array($pending) && $pending["time"] + self::PENDING_LIFETIME > time(); + $cancelled = rcube_utils::get_input_string("_userli_cancel", rcube_utils::INPUT_GET) !== ""; + $this->pending = $valid && !$cancelled ? $pending : null; + } + + // Dovecot rejects the webmail credential after it expired, so end the session with it + if (!empty($_SESSION["user_id"]) && isset($_SESSION["userli_expires"]) && $_SESSION["userli_expires"] <= time()) { + $rcmail->kill_session(); + } + + return $args; } /** - * Store user password during authentication + * Change the username placeholder and, while a 2FA login is pending, ask for the code */ - public function modify_login_form($args): void + public function modify_login_form(array $args): array { $this->api->output->add_script( "document.addEventListener('DOMContentLoaded', function() { @@ -42,14 +78,159 @@ public function modify_login_form($args): void } });" ); + + if ($this->pending === null) { + return $args; + } + + // Written here because the login action resets the session after the authenticate hook + $_SESSION["userli_2fa"] = $this->pending; + + $user = new html_hiddenfield(["name" => "_user", "value" => $this->pending["user"]]); + $code = new html_inputfield([ + "name" => "_userli_code", + "id" => "rcmloginuserlicode", + "required" => "required", + "autocomplete" => "one-time-code", + "autofocus" => "autofocus", + "data-icon" => "key", + ]); + + $args["hidden"]["user"] = $user->show(); + $args["inputs"] = [ + "code" => [ + "title" => html::label("rcmloginuserlicode", html::quote($this->gettext("twofactor_code"))), + "content" => $code->show(), + ], + ]; + $args["buttons"]["userli_cancel"] = [ + "outterclass" => "formbuttons", + "content" => html::a(["href" => "./?_task=login&_userli_cancel=1"], html::quote($this->gettext("use_another_account"))), + ]; + + return $args; } /** - * Store user password during authentication + * Exchange the password (and 2FA code) for a webmail credential, which is used for IMAP and SMTP */ - public function authenticate_store_pass($args): void + public function authenticate(array $args): array { + // Roundcube rejects the request anyway (missing CSRF token or session) + if (empty($args["valid"])) { + return $args; + } + + $rcmail = rcmail::get_instance(); + $code = trim((string) rcube_utils::get_input_string("_userli_code", rcube_utils::INPUT_POST)); + $pending = $this->pending; + $this->pending = null; + + if ($code !== "") { + if ($pending === null || $pending["user"] !== $args["user"]) { + $args["abort"] = true; + $args["error"] = "userli.twofactor_expired"; + + return $args; + } + + $args["pass"] = (string) $rcmail->decrypt($pending["pass"]); + } else { + $pending = null; + } + + // The aliases API needs the password, the credential is rejected there $this->pass = $args["pass"]; + + $response = $this->request_api("login", [ + "email" => $args["user"], + "password" => $args["pass"], + "code" => $code, + ]); + $status = $response["status"] ?? null; + $body = $response["body"] ?? []; + + if ($status === 404) { + rcube::write_log("userli", "Login endpoint not found, logging in without 2FA"); + + return $args; + } + + if ($status === 200 && is_string($body["password"] ?? null) && $body["password"] !== "") { + $this->credential = $body["password"]; + $args["pass"] = $body["password"]; + $expires = is_string($body["expiresAt"] ?? null) ? strtotime($body["expiresAt"]) : false; + $this->expires = $expires === false ? null : $expires; + + return $args; + } + + $args["abort"] = true; + $message = $body["message"] ?? ""; + + if ($status === 401 && $pending !== null) { + // Wrong code: let the user try again until the pending login expires + $this->pending = $pending; + $args["error"] = "userli.twofactor_invalid"; + } elseif ($status === 401 && $message === "twofactor required") { + $this->pending = [ + "user" => $args["user"], + "pass" => $rcmail->encrypt($args["pass"]), + "time" => time(), + ]; + $args["error"] = "userli.twofactor_required"; + $this->twofactor_required = true; + } elseif ($status === 429) { + $args["error"] = "userli.too_many_attempts"; + } elseif ($status === 403 && $message === "user password change required") { + $args["error"] = "userli.password_change_required"; + } elseif ($status !== 401 && $status !== 403) { + $args["error"] = "userli.login_unavailable"; + } + + return $args; + } + + /** + * Answer the code step with 200: the password was right, so brute-force detection must not count it + */ + public function unauthenticated(array $args): array + { + if ($this->twofactor_required) { + $args["http_code"] = 200; + } + + return $args; + } + + /** + * Revoke a credential that Roundcube could not use + */ + public function login_failed(array $args): array + { + if ($this->credential !== null) { + $this->request_api("logout", ["email" => $args["user"], "password" => $this->credential]); + $this->credential = null; + } + + return $args; + } + + /** + * Revoke the webmail credential of the ending session + */ + public function session_destroy(array $args): array + { + $rcmail = rcmail::get_instance(); + + if (!empty($_SESSION["user_id"])) { + $this->request_api("logout", [ + "email" => $rcmail->get_user_name(), + "password" => $rcmail->get_user_password(), + ]); + } + + return $args; } /** @@ -58,27 +239,17 @@ public function authenticate_store_pass($args): void public function login_after_update_identities(): void { $rcmail = rcmail::get_instance(); - $this->load_config(); + + if ($this->expires !== null) { + $_SESSION["userli_expires"] = $this->expires; + } // Get list of userli aliases - $client = $rcmail->get_http_client(); try { - $response = $client->request( - 'POST', - $rcmail->config->get("userli_url") . '/api/roundcube/aliases', - [ - "verify" => $rcmail->config->get( - "userli_ssl_verify", - ), - "json" => [ - "email" => $rcmail->user->get_username(), - "password" => $this->pass, - ], - "headers" => [ - 'X-API-Token' => $rcmail->config->get("userli_token") - ], - ], - ); + $response = $this->request("aliases", [ + "email" => $rcmail->user->get_username(), + "password" => $this->pass, + ]); $response_code = $response->getStatusCode(); $result = (string) $response->getBody(); } catch (Exception $e) { @@ -166,4 +337,55 @@ public function login_after_update_identities(): void ]); } } + + /** + * POST to the userli Roundcube API, without throwing on 4xx/5xx responses + */ + private function request(string $path, array $data) + { + $rcmail = rcmail::get_instance(); + $this->load_config(); + + return $rcmail->get_http_client()->request( + 'POST', + $rcmail->config->get("userli_url") . '/api/roundcube/' . $path, + [ + "verify" => $rcmail->config->get("userli_ssl_verify"), + "http_errors" => false, + "json" => $data, + "headers" => [ + 'X-API-Token' => $rcmail->config->get("userli_token"), + ], + ], + ); + } + + /** + * Like request(), but returns status and decoded body, or null and logs the error if userli is unreachable + */ + private function request_api(string $path, array $data): ?array + { + try { + $response = $this->request($path, $data); + } catch (Exception $e) { + rcube::raise_error( + [ + "code" => 600, + "file" => __FILE__, + "line" => __LINE__, + "message" => "Userli plugin: " . $e->getMessage(), + ], + true, + false, + ); + return null; + } + + $body = json_decode((string) $response->getBody(), true); + + return [ + "status" => $response->getStatusCode(), + "body" => is_array($body) ? $body : [], + ]; + } }