Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions localization/de_DE.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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.';
4 changes: 4 additions & 0 deletions localization/en_US.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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.';
Binary file added screenshots/security-key.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions tests/UserliLoginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
Expand Down
28 changes: 28 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,41 @@ 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);
}
};
}

protected function add_texts($dir)
{
}

public function include_script($fn)
{
$this->scripts[] = $fn;
}

public function gettext($name)
{
return $name;
Expand Down Expand Up @@ -156,6 +175,15 @@ public static function a($attrib, $content)
return '<a href="' . htmlspecialchars($attrib['href']) . '">' . $content . '</a>';
}

public static function tag($tagname, $attrib = [], $content = '')
{
$attrs = '';
foreach ($attrib as $key => $value) {
$attrs .= ' ' . $key . '="' . htmlspecialchars((string) $value) . '"';
}
return '<' . $tagname . $attrs . '>' . $content . '</' . $tagname . '>';
}

public static function quote($str)
{
return htmlspecialchars((string) $str);
Expand Down
87 changes: 87 additions & 0 deletions userli.js
Original file line number Diff line number Diff line change
@@ -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();
}
})();
Loading