diff --git a/README.md b/README.md index c5f4dbe..eb87d29 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ 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. +* **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. Users with security keys can use their key there instead. This needs a Userli version with security keys, a common parent domain of Userli and Roundcube as *Security Key Domain*, and the Roundcube origin in *Additional Security Key Origins* (see the [Userli documentation](https://systemli.github.io/userli/operations/integrations/roundcube/#security-keys)). 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) + + ![Security key step](screenshots/security-key.png) * **Alias identities:** at login, the plugin creates a Roundcube identity for each alias of the user. ![Alias identities](screenshots/aliases.png) diff --git a/localization/de_DE.inc b/localization/de_DE.inc index a1a2aae..7ef8a36 100644 --- a/localization/de_DE.inc +++ b/localization/de_DE.inc @@ -2,6 +2,7 @@ $labels = []; $labels['twofactor_code'] = 'Zwei-Faktor-Code'; +$labels['use_security_key'] = 'Sicherheitsschlüssel verwenden'; $labels['use_another_account'] = 'Anderes Konto verwenden'; $messages = []; @@ -11,3 +12,6 @@ $messages['twofactor_expired'] = 'Die Anmeldung hat zu lange gedauert. Bitte gib $messages['too_many_attempts'] = 'Zu viele Anmeldeversuche. Bitte versuche es später erneut.'; $messages['password_change_required'] = 'Bitte ändere zuerst dein Passwort in deinen Kontoeinstellungen.'; $messages['login_unavailable'] = 'Die Anmeldung ist gerade nicht möglich. Bitte versuche es später erneut.'; +$messages['twofactor_required_webauthn'] = 'Verwende deinen Sicherheitsschlüssel oder gib den Code aus deiner Authenticator-App oder einen Backup-Code ein.'; +$messages['webauthn_invalid'] = 'Der Sicherheitsschlüssel konnte nicht überprüft werden. Bitte versuche es erneut.'; +$messages['webauthn_failed'] = 'Der Sicherheitsschlüssel wurde nicht verwendet. Bitte versuche es erneut oder gib einen Code ein.'; diff --git a/localization/en_US.inc b/localization/en_US.inc index b68c44f..ceac7fc 100644 --- a/localization/en_US.inc +++ b/localization/en_US.inc @@ -2,6 +2,7 @@ $labels = []; $labels['twofactor_code'] = 'Two-factor code'; +$labels['use_security_key'] = 'Use security key'; $labels['use_another_account'] = 'Use another account'; $messages = []; @@ -11,3 +12,6 @@ $messages['twofactor_expired'] = 'The login took too long. Please enter your pas $messages['too_many_attempts'] = 'Too many login attempts. Please try again later.'; $messages['password_change_required'] = 'Please change your password in your account settings first.'; $messages['login_unavailable'] = 'Login is currently not possible. Please try again later.'; +$messages['twofactor_required_webauthn'] = 'Use your security key, or enter the code from your authenticator app or a backup code.'; +$messages['webauthn_invalid'] = 'The security key could not be verified. Please try again.'; +$messages['webauthn_failed'] = 'The security key was not used. Please try again or enter a code.'; diff --git a/screenshots/security-key.png b/screenshots/security-key.png new file mode 100644 index 0000000..c54b731 Binary files /dev/null and b/screenshots/security-key.png differ diff --git a/tests/UserliLoginTest.php b/tests/UserliLoginTest.php index 39aa1da..953970f 100644 --- a/tests/UserliLoginTest.php +++ b/tests/UserliLoginTest.php @@ -197,6 +197,93 @@ public function testWrongCodeKeepsCodeForm(): void $this->assertSame('totp@example.org', $_SESSION['userli_2fa']['user']); } + public function testCodeFormWithoutSecurityKeysHasNoKeyButton(): 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->assertArrayNotHasKey('userli_webauthn', $form['buttons']); + $this->assertSame([], $this->plugin->scripts); + $this->assertNull($_SESSION['userli_2fa']['webauthn']); + } + + public function testTwofactorRequiredWithSecurityKeyOffersKey(): void + { + $options = ['challenge' => 'Y2hhbGxlbmdl', 'rpId' => 'example.org', 'allowCredentials' => [['type' => 'public-key', 'id' => 'Y3JlZA']]]; + $this->client(new FakeResponse(401, ['message' => 'twofactor required', 'webauthn' => $options])); + + $args = $this->login('key@example.org', 'password'); + $form = $this->plugin->modify_login_form(['hidden' => [], 'inputs' => [], 'buttons' => []]); + + $this->assertSame('userli.twofactor_required_webauthn', $args['error']); + $this->assertSame(['userli.js'], $this->plugin->scripts); + $this->assertSame($options, $this->plugin->api->output->env['userli_webauthn']); + $this->assertContains('userli.webauthn_failed', $this->plugin->api->output->labels); + $this->assertStringContainsString('name="_userli_webauthn"', $form['hidden']['userli_webauthn']); + $this->assertStringContainsString('id="rcmloginuserlikey"', $form['buttons']['userli_webauthn']['content']); + $this->assertSame($options, $_SESSION['userli_2fa']['webauthn']); + } + + public function testSecurityKeyStepSendsAssertionWithIssuedChallenge(): void + { + $_SESSION['userli_2fa'] = ['user' => 'key@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time(), 'webauthn' => ['challenge' => 'aXNzdWVk']]; + $_POST['_userli_webauthn'] = json_encode([ + 'challenge' => 'b3RoZXI', + 'credentialId' => 'Y3JlZA', + 'clientDataJSON' => 'Y2xpZW50', + 'authenticatorData' => 'YXV0aA', + 'signature' => 'c2ln', + 'userHandle' => null, + ]); + $client = $this->client(new FakeResponse(200, ['password' => 'credential'])); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('key@example.org', ''); + + $this->assertSame('credential', $args['pass']); + $this->assertSame([ + 'email' => 'key@example.org', + 'password' => 'password', + 'code' => '', + 'webauthn' => [ + 'credentialId' => 'Y3JlZA', + 'clientDataJSON' => 'Y2xpZW50', + 'authenticatorData' => 'YXV0aA', + 'signature' => 'c2ln', + 'challenge' => 'aXNzdWVk', + ], + ], $client->calls[0]['json']); + } + + public function testRejectedSecurityKeyKeepsStepWithNewChallenge(): void + { + $_SESSION['userli_2fa'] = ['user' => 'key@example.org', 'pass' => $this->rcmail->encrypt('password'), 'time' => time(), 'webauthn' => ['challenge' => 'b2xk']]; + $_POST['_userli_webauthn'] = json_encode(['credentialId' => 'Y3JlZA']); + $this->client(new FakeResponse(401, ['message' => 'authentication failed', 'webauthn' => ['challenge' => 'bmV3']])); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('key@example.org', ''); + $this->plugin->modify_login_form(['hidden' => [], 'inputs' => [], 'buttons' => []]); + + $this->assertSame('userli.webauthn_invalid', $args['error']); + $this->assertSame(['challenge' => 'bmV3'], $_SESSION['userli_2fa']['webauthn']); + $this->assertSame(401, $this->plugin->unauthenticated(['task' => 'login', 'http_code' => 401])['http_code']); + } + + public function testSecurityKeyWithoutPendingLoginIsNotSent(): void + { + $_POST['_userli_webauthn'] = json_encode(['credentialId' => 'Y3JlZA']); + $client = $this->client(); + + $this->plugin->startup(['task' => 'login', 'action' => 'login']); + $args = $this->login('key@example.org', ''); + + $this->assertSame([], $client->calls); + $this->assertSame('userli.twofactor_expired', $args['error']); + } + public function testCodeFormOffersAnotherAccount(): void { $this->client(new FakeResponse(401, ['message' => 'twofactor required'])); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c44b143..3375fcf 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,15 +12,29 @@ class rcube_plugin { public $task; public $api; + public $scripts = []; private $hooks = []; public function __construct() { $this->api = new stdClass(); $this->api->output = new class { + public $env = []; + public $labels = []; + public function add_script($script) { } + + public function set_env($name, $value) + { + $this->env[$name] = $value; + } + + public function add_label(...$names) + { + array_push($this->labels, ...$names); + } }; } @@ -28,6 +42,11 @@ protected function add_texts($dir) { } + public function include_script($fn) + { + $this->scripts[] = $fn; + } + public function gettext($name) { return $name; @@ -156,6 +175,15 @@ public static function a($attrib, $content) return '' . $content . ''; } + public static function tag($tagname, $attrib = [], $content = '') + { + $attrs = ''; + foreach ($attrib as $key => $value) { + $attrs .= ' ' . $key . '="' . htmlspecialchars((string) $value) . '"'; + } + return '<' . $tagname . $attrs . '>' . $content . ''; + } + public static function quote($str) { return htmlspecialchars((string) $str); diff --git a/userli.js b/userli.js new file mode 100644 index 0000000..1cea169 --- /dev/null +++ b/userli.js @@ -0,0 +1,87 @@ +/** + * Userli: sign in with a security key in the two-factor step + * + * Passes the options from userli (env userli_webauthn) to the browser and + * submits the assertion, base64url encoded, in the hidden field _userli_webauthn. + */ + +(function () { + function decode(value) { + var base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + var binary = atob(base64 + "===".slice((base64.length + 3) % 4)); + return Uint8Array.from(binary, function (c) { + return c.charCodeAt(0); + }); + } + + function encode(buffer) { + var binary = String.fromCharCode.apply(null, new Uint8Array(buffer)); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function useSecurityKey(button) { + var options = rcmail.env.userli_webauthn; + var field = document.getElementById("rcmloginuserliwebauthn"); + var form = field.form; + var publicKey = Object.assign({}, options, { + challenge: decode(options.challenge), + allowCredentials: (options.allowCredentials || []).map(function (credential) { + return Object.assign({}, credential, { id: decode(credential.id) }); + }), + }); + + button.disabled = true; + navigator.credentials + .get({ publicKey: publicKey }) + .then(function (credential) { + var response = credential.response; + field.value = JSON.stringify({ + credentialId: encode(credential.rawId), + clientDataJSON: encode(response.clientDataJSON), + authenticatorData: encode(response.authenticatorData), + signature: encode(response.signature), + userHandle: response.userHandle ? encode(response.userHandle) : null, + }); + + // The code is not needed with a security key + if (form.elements._userli_code) { + form.elements._userli_code.required = false; + form.elements._userli_code.value = ""; + } + + // Like the submit button, so Roundcube shows its loading state + if (form.requestSubmit) { + form.requestSubmit(); + } else { + form.submit(); + } + }) + .catch(function () { + button.disabled = false; + rcmail.display_message(rcmail.get_label("webauthn_failed", "userli"), "error"); + }); + } + + function init() { + var button = document.getElementById("rcmloginuserlikey"); + if (!button) { + return; + } + + if (!window.PublicKeyCredential || !rcmail.env.userli_webauthn) { + button.hidden = true; + return; + } + + button.addEventListener("click", function () { + useSecurityKey(button); + }); + } + + // Roundcube's init event may have fired before plugin scripts run, the env is set once the DOM is parsed + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/userli.php b/userli.php index d59ba0f..daaa97d 100644 --- a/userli.php +++ b/userli.php @@ -3,8 +3,8 @@ /** * Userli * - * Logs in with two-factor authentication through the userli API and - * updates user identities from userli aliases at login + * Logs in with two-factor authentication (code or security key) through the + * userli API and updates user identities from userli aliases at login * * @version 0.2 * @author systemli @@ -87,6 +87,7 @@ public function modify_login_form(array $args): array $_SESSION["userli_2fa"] = $this->pending; $user = new html_hiddenfield(["name" => "_user", "value" => $this->pending["user"]]); + $webauthn = $this->pending["webauthn"] ?? null; $code = new html_inputfield([ "name" => "_userli_code", "id" => "rcmloginuserlicode", @@ -103,6 +104,22 @@ public function modify_login_form(array $args): array "content" => $code->show(), ], ]; + if (is_array($webauthn)) { + // userli.js passes the options to the browser and submits the assertion in _userli_webauthn + $this->include_script("userli.js"); + $this->api->output->set_env("userli_webauthn", $webauthn); + $this->api->output->add_label("userli.webauthn_failed"); + $assertion = new html_hiddenfield(["name" => "_userli_webauthn", "id" => "rcmloginuserliwebauthn"]); + $args["hidden"]["userli_webauthn"] = $assertion->show(); + $args["buttons"]["userli_webauthn"] = [ + "outterclass" => "formbuttons", + "content" => html::tag( + "button", + ["type" => "button", "id" => "rcmloginuserlikey", "class" => "button"], + html::quote($this->gettext("use_security_key")), + ), + ]; + } $args["buttons"]["userli_cancel"] = [ "outterclass" => "formbuttons", "content" => html::a(["href" => "./?_task=login&_userli_cancel=1"], html::quote($this->gettext("use_another_account"))), @@ -112,7 +129,7 @@ public function modify_login_form(array $args): array } /** - * Exchange the password (and 2FA code) for a webmail credential, which is used for IMAP and SMTP + * Exchange the password (and 2FA code or security key assertion) for a webmail credential, which is used for IMAP and SMTP */ public function authenticate(array $args): array { @@ -123,10 +140,11 @@ public function authenticate(array $args): array $rcmail = rcmail::get_instance(); $code = trim((string) rcube_utils::get_input_string("_userli_code", rcube_utils::INPUT_POST)); + $assertion = $this->get_webauthn_assertion(); $pending = $this->pending; $this->pending = null; - if ($code !== "") { + if ($code !== "" || $assertion !== null) { if ($pending === null || $pending["user"] !== $args["user"]) { $args["abort"] = true; $args["error"] = "userli.twofactor_expired"; @@ -135,6 +153,10 @@ public function authenticate(array $args): array } $args["pass"] = (string) $rcmail->decrypt($pending["pass"]); + if ($assertion !== null) { + // The challenge userli issued for this login, not one from the browser + $assertion["challenge"] = (string) ($pending["webauthn"]["challenge"] ?? ""); + } } else { $pending = null; } @@ -142,11 +164,16 @@ public function authenticate(array $args): array // The aliases API needs the password, the credential is rejected there $this->pass = $args["pass"]; - $response = $this->request_api("login", [ + $data = [ "email" => $args["user"], "password" => $args["pass"], "code" => $code, - ]); + ]; + if ($assertion !== null) { + $data["webauthn"] = $assertion; + } + + $response = $this->request_api("login", $data); $status = $response["status"] ?? null; $body = $response["body"] ?? []; @@ -169,16 +196,18 @@ public function authenticate(array $args): array $message = $body["message"] ?? ""; if ($status === 401 && $pending !== null) { - // Wrong code: let the user try again until the pending login expires + // Wrong code or key: let the user try again until the pending login expires, with a new challenge + $pending["webauthn"] = $this->get_webauthn_options($body); $this->pending = $pending; - $args["error"] = "userli.twofactor_invalid"; + $args["error"] = $assertion !== null ? "userli.webauthn_invalid" : "userli.twofactor_invalid"; } elseif ($status === 401 && $message === "twofactor required") { $this->pending = [ "user" => $args["user"], "pass" => $rcmail->encrypt($args["pass"]), "time" => time(), + "webauthn" => $this->get_webauthn_options($body), ]; - $args["error"] = "userli.twofactor_required"; + $args["error"] = $this->pending["webauthn"] !== null ? "userli.twofactor_required_webauthn" : "userli.twofactor_required"; $this->twofactor_required = true; } elseif ($status === 429) { $args["error"] = "userli.too_many_attempts"; @@ -338,6 +367,38 @@ public function login_after_update_identities(): void } } + /** + * Options for navigator.credentials.get() that userli sends for users with security keys + */ + private function get_webauthn_options(array $body): ?array + { + $options = $body["webauthn"] ?? null; + + return is_array($options) && is_string($options["challenge"] ?? null) ? $options : null; + } + + /** + * The assertion that userli.js submitted, base64url encoded, or null without one + */ + private function get_webauthn_assertion(): ?array + { + $json = (string) rcube_utils::get_input_string("_userli_webauthn", rcube_utils::INPUT_POST); + if ($json === "") { + return null; + } + + $data = json_decode($json, true); + $assertion = []; + foreach (["credentialId", "clientDataJSON", "authenticatorData", "signature", "userHandle"] as $key) { + if (is_array($data) && is_string($data[$key] ?? null) && $data[$key] !== "") { + $assertion[$key] = $data[$key]; + } + } + + // Incomplete assertions are sent anyway, userli rejects them + return $assertion; + } + /** * POST to the userli Roundcube API, without throwing on 4xx/5xx responses */