diff --git a/management/auth.py b/management/auth.py index 1ae46d1e..e9470449 100644 --- a/management/auth.py +++ b/management/auth.py @@ -1,4 +1,4 @@ -import base64, os, os.path, hmac +import base64, os, os.path, hmac, json from flask import make_response @@ -97,6 +97,17 @@ class KeyAuthService: # email address does not correspond to a user. pw_hash = get_mail_password(email, env) + # If 2FA is set up, get the first factor and authenticate against + # that first. + twofa = None + if pw_hash.startswith("{TOTP}"): + twofa = json.loads(pw_hash[6:]) + pw_hash = twofa["first_factor"] + try: + pw, twofa_code = pw.split(" ", 1) + except: + twofa_code = "" + # Authenticate. try: # Use 'doveadm pw' to check credentials. doveadm will return @@ -111,6 +122,14 @@ class KeyAuthService: # Login failed. raise ValueError("Invalid password.") + # Check second factor. + if twofa: + import oath + ok, drift = oath.accept_totp(twofa["secret"], twofa_code, drift=twofa["drift"]) + if not ok: + raise ValueError("Invalid 2FA code.") + + # Get privileges for authorization. # (This call should never fail on a valid user. But if it did fail, it would diff --git a/management/daemon.py b/management/daemon.py index bc2099b8..99c14797 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -7,7 +7,7 @@ from functools import wraps from flask import Flask, request, render_template, abort, Response import auth, utils -from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user +from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, remove_mail_user, get_mail_password from mailconfig import get_mail_user_privileges, add_remove_mail_user_privilege from mailconfig import get_mail_aliases, get_mail_aliases_ex, get_mail_domains, add_mail_alias, remove_mail_alias @@ -40,6 +40,7 @@ def authorized_personnel_only(viewfunc): # Authorized to access an API view? if "admin" in privs: # Call view func. + request.user_email = email return viewfunc(*args, **kwargs) elif not error: error = "You are not an administrator." @@ -115,6 +116,81 @@ def me(): # Return. return json_response(resp) +# ME + +@app.route('/me/2fa') +@authorized_personnel_only +def twofa_status(): + pw = get_mail_password(request.user_email, env) + if pw.startswith("{SHA512-CRYPT}"): + method = "password-only" + elif pw.startswith("{TOTP}"): + method = "TOTP 2FA" + else: + method = "unknown" + + return json_response({ + "method": method + }) + +@app.route('/me/2fa/totp/initialize', methods=['POST']) +@authorized_personnel_only +def twofa_initialize(): + # Generate a Google Authenticator URI that encodes TOTP info. + import urllib.parse, base64, qrcode, io, binascii + + secret = os.urandom(32) + uri = "otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&algorithm=%s" % ( + urllib.parse.quote(env['PRIMARY_HOSTNAME']), + urllib.parse.quote(request.user_email), + base64.b32encode(secret).decode("ascii").lower().replace("=", ""), + urllib.parse.quote(env['PRIMARY_HOSTNAME']), + 6, + "sha1" + ) + + image_buffer = io.BytesIO() + im = qrcode.make(uri) + im.save(image_buffer, 'png') + + return json_response({ + "uri": uri, + "secret": binascii.hexlify(secret).decode('ascii'), + "qr": base64.b64encode(image_buffer.getvalue()).decode('ascii') + }) + +@app.route('/me/2fa/totp/activate', methods=['POST']) +@authorized_personnel_only +def twofa_activate(): + import oath + ok, drift = oath.accept_totp(request.form['secret'], request.form['code']) + if ok: + # use the user's current plain password as the first_factor + # of 2FA. + existing_pw = get_mail_password(request.user_email, env) + if existing_pw.startswith("{TOTP}"): + existing_pw = json.loads(existing_pw)["first_factor"] + + pw = "{TOTP}" + json.dumps({ + "secret": request.form['secret'], + "drift": drift, + "first_factor": existing_pw, + }) + + set_mail_password(request.user_email, pw, env, already_hashed=True) + + return json_response({ + "status": "ok", + "message": "TOTP 2FA installed." + }) + + else: + return json_response({ + "status": "fail", + "message": "The activation code was not right. Try again?" + }) + + # MAIL @app.route('/mail/users') diff --git a/management/mailconfig.py b/management/mailconfig.py index b95ee87e..863423ff 100755 --- a/management/mailconfig.py +++ b/management/mailconfig.py @@ -311,15 +311,16 @@ def add_mail_user(email, pw, privs, env): # Update things in case any new domains are added. return kick(env, "mail user added") -def set_mail_password(email, pw, env): +def set_mail_password(email, pw, env, already_hashed=False): # accept IDNA domain names but normalize to Unicode before going into database email = sanitize_idn_email_address(email) # validate that password is acceptable - validate_password(pw) - - # hash the password - pw = hash_password(pw) + if not already_hashed: + # Validate and hash the password. Skip if we're providing + # a raw hashed password value. + validate_password(pw) + pw = hash_password(pw) # update the database conn, c = open_database(env, with_connection=True) diff --git a/management/templates/2fa.html b/management/templates/2fa.html new file mode 100644 index 00000000..6a595a3b --- /dev/null +++ b/management/templates/2fa.html @@ -0,0 +1,79 @@ + + +

Two-Factor Authentication

+ +

Two-factor authentication (2FA) is something you know and something you have.

+ +

Regular password-based logins are one-factor (something you know). 2FA makes an account more secure by guarding against a lost or guessed password, since you also need a special device to access your account. You can turn on 2FA for your account here.

+ +

Your authentication method is currently:

+ +

TOTP

+ +

TOTP is a time-based one-time password method of two-factor authentication.

+ +

You will need a TOTP-compatible device, such as any Android device with the FreeOTP Authenticator app. We’ll generate a QR code that you import into your device or app. After you generate the QR code, you’ll activate 2FA by entering your first activation code provided by your device or app.

+ +

+ + +

When using TOTP 2FA, your password becomes your previous plain password plus a space plus the code generated by your TOTP device.

+ + diff --git a/management/templates/index.html b/management/templates/index.html index 2efa6250..90b2233c 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -115,6 +115,12 @@
  • Contacts/Calendar
  • Web
  • +