diff --git a/management/daemon.py b/management/daemon.py index 2752593..f4f972d 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -128,12 +128,18 @@ def me(): try: email, privs = auth_service.authenticate(request, env) except ValueError as e: - # Log the failed login - log_failed_login(request) - return json_response({ - "status": "invalid", - "reason": str(e), - }) + if "missing-totp-token" in str(e): + return json_response({ + "status": "missing-totp-token", + "reason": str(e), + }) + else: + # Log the failed login + log_failed_login(request) + return json_response({ + "status": "invalid", + "reason": str(e), + }) resp = { "status": "ok", @@ -408,11 +414,12 @@ def mfa_get_status(): def totp_post_enable(): secret = request.form.get('secret') token = request.form.get('token') + label = request.form.get('label') if type(token) != str: return json_response({ "error": 'bad_input' }, 400) try: validate_totp_secret(secret) - enable_mfa(request.user_email, "totp", secret, token, env) + enable_mfa(request.user_email, "totp", secret, token, label, env) except ValueError as e: return str(e) return "OK" diff --git a/management/mfa.py b/management/mfa.py index af696ac..4db0ac9 100644 --- a/management/mfa.py +++ b/management/mfa.py @@ -15,13 +15,13 @@ def get_user_id(email, c): def get_mfa_state(email, env): c = open_database(env) - c.execute('SELECT id, type, secret, mru_token FROM mfa WHERE user_id=?', (get_user_id(email, c),)) + c.execute('SELECT id, type, secret, mru_token, label FROM mfa WHERE user_id=?', (get_user_id(email, c),)) return [ - { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3] } + { "id": r[0], "type": r[1], "secret": r[2], "mru_token": r[3], "label": r[4] } for r in c.fetchall() ] -def enable_mfa(email, type, secret, token, env): +def enable_mfa(email, type, secret, token, label, env): if type == "totp": validate_totp_secret(secret) # Sanity check with the provide current token. @@ -32,7 +32,7 @@ def enable_mfa(email, type, secret, token, env): raise ValueError("Invalid MFA type.") conn, c = open_database(env, with_connection=True) - c.execute('INSERT INTO mfa (user_id, type, secret) VALUES (?, ?, ?)', (get_user_id(email, c), type, secret)) + c.execute('INSERT INTO mfa (user_id, type, secret, label) VALUES (?, ?, ?, ?)', (get_user_id(email, c), type, secret, label)) conn.commit() def set_mru_token(email, token, env): diff --git a/management/templates/index.html b/management/templates/index.html index 8fdb7c2..12f6ad8 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -93,16 +93,18 @@
  • Custom DNS
  • External DNS
  • -
  • Two-Factor Authentication
  • Munin Monitoring
  • Contacts/Calendar
  • @@ -132,8 +134,8 @@ {% include "custom-dns.html" %} -
    - {% include "two-factor-auth.html" %} +
    + {% include "mfa.html" %}
    diff --git a/management/templates/login.html b/management/templates/login.html index db8dce8..67cb08d 100644 --- a/management/templates/login.html +++ b/management/templates/login.html @@ -61,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}
    +
    + +
    + +
    Enter the six-digit code generated by your two factor authentication app.
    +
    +
    @@ -70,12 +77,6 @@ sudo tools/mail.py user make-admin me@{{hostname}}
    -
    -
    - - -
    -
    @@ -111,13 +112,18 @@ function do_login() { // This API call always succeeds. It returns a JSON object indicating // whether the request was authenticated or not. if (response.status != 'ok') { - if (response.status === 'missing_token' && !$('#loginForm').hasClass('is-twofactor')) { + if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) { $('#loginForm').addClass('is-twofactor'); - setTimeout(() => { - $('#loginOtpInput').focus(); - }); + if (response.reason === "invalid-totp-token") { + show_modal_error("Login Failed", "Incorrect two factor authentication token."); + } else { + setTimeout(() => { + $('#loginOtpInput').focus(); + }); + } } else { $('#loginForm').removeClass('is-twofactor'); + // Show why the login failed. show_modal_error("Login Failed", response.reason) diff --git a/management/templates/two-factor-auth.html b/management/templates/mfa.html similarity index 60% rename from management/templates/two-factor-auth.html rename to management/templates/mfa.html index 8108c21..32b7f6c 100644 --- a/management/templates/two-factor-auth.html +++ b/management/templates/mfa.html @@ -33,38 +33,65 @@

    Two-Factor Authentication

    +

    When two-factor authentication is enabled, you will be prompted to enter a six digit code from an +authenticator app (usually on your phone) when you log into this control panel.

    + +
    +
    +Enabling two-factor authentication does not protect access to your email +
    +
    +Enabling two-factor authentication on this page only limits access to this control panel. Remember that most websites allow you to +reset your password by checking your email, so anyone with access to your email can typically take over +your other accounts. Additionally, if your email address or any alias that forwards to your email +address is a typical domain control validation address (e.g admin@, administrator@, postmaster@, hostmaster@, +webmaster@, abuse@), extra care should be taken to protect the account. Always use a strong password, +and ensure every administrator account for this control panel does the same. +
    +
    +
    Loading...
    -

    After enabling two-factor authentication, any login to the admin panel will require you to enter a time-limited 6-digit number from an authenticator app after entering your normal credentials.

    +

    Setup Instructions

    -

    Setup Instructions

    -

    1. Scan the QR code or enter the secret into an authenticator app (e.g. Google Authenticator)

    +

    1. Install FreeOTP or any + other two-factor authentication app that supports TOTP.

    +
    + +
    +

    2. Scan the QR code in the app or directly enter the secret into the app:

    - -

    You will have to log into the admin panel again after enabling two-factor authentication.

    + + +
    + +
    +
    - +

    When you click Enable Two-Factor Authentication, you will be logged out of the control panel and will have to log in + again, now using your two-factor authentication app.

    +
    -

    Two-factor authentication is active for your account. You can disable it by clicking below button.

    +

    Two-factor authentication is active for your account.

    You will have to log into the admin panel again after disabling two-factor authentication.

    - +
    @@ -80,6 +107,7 @@ totpSetupForm: document.getElementById('totp-setup'), totpSetupToken: document.getElementById('totp-setup-token'), totpSetupSecret: document.getElementById('totp-setup-secret'), + totpSetupLabel: document.getElementById('totp-setup-label'), totpQr: document.getElementById('totp-setup-qr'), totpSetupSubmit: document.querySelector('#totp-setup-submit'), wrapper: document.querySelector('.twofactor') @@ -101,30 +129,29 @@ } } - function render_totp_setup(res) { - function render_qr_code(encoded) { - var img = document.createElement('img'); - img.src = encoded; + function render_totp_setup(provisioned_totp) { + var img = document.createElement('img'); + img.src = "data:image/png;base64," + provisioned_totp.qr_code_base64; - var code = document.createElement('div'); - code.innerHTML = `Secret: ${res.totp_secret}`; + var code = document.createElement('div'); + code.innerHTML = `Secret: ${provisioned_totp.secret}`; - el.totpQr.appendChild(img); - el.totpQr.appendChild(code); - } + el.totpQr.appendChild(img); + el.totpQr.appendChild(code); el.totpSetupToken.addEventListener('input', update_setup_disabled); el.totpSetupForm.addEventListener('submit', do_enable_totp); - el.totpSetupSecret.setAttribute('value', res.totp_secret); - render_qr_code(res.totp_qr); + el.totpSetupSecret.setAttribute('value', provisioned_totp.secret); el.wrapper.classList.add('disabled'); } - function render_disable() { + function render_disable(mfa) { el.disableForm.addEventListener('submit', do_disable); el.wrapper.classList.add('enabled'); + if (mfa.label) + $("#mfa-device-label").text(" on device '" + mfa.label + "'"); } function hide_error() { @@ -154,7 +181,7 @@ el.totpQr.innerHTML = ''; } - function show_two_factor_auth() { + function show_mfa() { reset_view(); api( @@ -163,8 +190,17 @@ {}, function(res) { el.wrapper.classList.add('loaded'); - var isTotpEnabled = res.type === 'totp' - return isTotpEnabled ? render_disable(res) : render_totp_setup(res); + + var has_mfa = false; + res.enabled_mfa.forEach(function(mfa) { + if (mfa.type == "totp") { + render_disable(mfa); + has_mfa = true; + } + }); + + if (!has_mfa) + render_totp_setup(res.new_mfa.totp); } ); } @@ -174,9 +210,9 @@ hide_error(); api( - '/mfa/totp/disable', + '/mfa/disable', 'POST', - {}, + { type: 'totp' }, function() { do_logout(); } @@ -194,7 +230,8 @@ 'POST', { token: $(el.totpSetupToken).val(), - secret: $(el.totpSetupSecret).val() + secret: $(el.totpSetupSecret).val(), + label: $(el.totpSetupLabel).val() }, function(res) { do_logout(); diff --git a/setup/mail-users.sh b/setup/mail-users.sh index 9fcdf79..b2625a3 100755 --- a/setup/mail-users.sh +++ b/setup/mail-users.sh @@ -22,7 +22,7 @@ if [ ! -f $db_path ]; then echo Creating new user database: $db_path; echo "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL, extra, privileges TEXT NOT NULL DEFAULT '');" | sqlite3 $db_path; echo "CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path; - echo "CREATE TABLE IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; + echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);" | sqlite3 $db_path; fi # ### User Authentication diff --git a/setup/migrate.py b/setup/migrate.py index 5b6e398..e4a253d 100755 --- a/setup/migrate.py +++ b/setup/migrate.py @@ -184,7 +184,7 @@ def migration_12(env): def migration_13(env): # Add the "mfa" table for configuring MFA for login to the control panel. db = os.path.join(env["STORAGE_ROOT"], 'mail/users.sqlite') - shell("check_call", ["sqlite3", db, "CREATE TABLE IF NOT EXISTS mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"]) + shell("check_call", ["sqlite3", db, "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"]) ###########################################################