1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2024-11-24 02:37:05 +00:00

TOTP two-factor authentication

This commit is contained in:
Joshua Tauberer 2014-12-01 20:12:02 +00:00
parent 1f0345fe0e
commit 6c843fc92e
6 changed files with 196 additions and 8 deletions

View File

@ -1,4 +1,4 @@
import base64, os, os.path, hmac import base64, os, os.path, hmac, json
from flask import make_response from flask import make_response
@ -97,6 +97,17 @@ class KeyAuthService:
# email address does not correspond to a user. # email address does not correspond to a user.
pw_hash = get_mail_password(email, env) 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. # Authenticate.
try: try:
# Use 'doveadm pw' to check credentials. doveadm will return # Use 'doveadm pw' to check credentials. doveadm will return
@ -111,6 +122,14 @@ class KeyAuthService:
# Login failed. # Login failed.
raise ValueError("Invalid password.") 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. # Get privileges for authorization.
# (This call should never fail on a valid user. But if it did fail, it would # (This call should never fail on a valid user. But if it did fail, it would

View File

@ -7,7 +7,7 @@ from functools import wraps
from flask import Flask, request, render_template, abort, Response from flask import Flask, request, render_template, abort, Response
import auth, utils 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_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 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? # Authorized to access an API view?
if "admin" in privs: if "admin" in privs:
# Call view func. # Call view func.
request.user_email = email
return viewfunc(*args, **kwargs) return viewfunc(*args, **kwargs)
elif not error: elif not error:
error = "You are not an administrator." error = "You are not an administrator."
@ -115,6 +116,81 @@ def me():
# Return. # Return.
return json_response(resp) 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 # MAIL
@app.route('/mail/users') @app.route('/mail/users')

View File

@ -311,15 +311,16 @@ def add_mail_user(email, pw, privs, env):
# Update things in case any new domains are added. # Update things in case any new domains are added.
return kick(env, "mail user 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 # accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email) email = sanitize_idn_email_address(email)
# validate that password is acceptable # validate that password is acceptable
validate_password(pw) if not already_hashed:
# Validate and hash the password. Skip if we're providing
# hash the password # a raw hashed password value.
pw = hash_password(pw) validate_password(pw)
pw = hash_password(pw)
# update the database # update the database
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)

View File

@ -0,0 +1,79 @@
<style>
</style>
<h2>Two-Factor Authentication</h2>
<p>Two-factor authentication (2FA) is <i>something you know</i> and <i>something you have</i>.</p>
<p>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.</p>
<p>Your authentication method is currently: <strong id="2fa_current"> </strong></p>
<h3>TOTP</h3>
<p>TOTP is a time-based one-time password method of two-factor authentication.</p>
<p>You will need a TOTP-compatible device, such as any Android device with the <a href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a> app. We&rsquo;ll generate a QR code that you import into your device or app. After you generate the QR code, you&rsquo;ll activate 2FA by entering your first activation code provided by your device or app.</p>
<p><button onclick="totp_initialize()">Generate QR Code</button></p>
<div id="totp-form" class="row" style="display: none">
<div class="col-sm-6">
<center>QR Code</center>
<img id="totp_qr_code" src="" class="img-responsive">
</div>
<div class="col-sm-6">
<form class="form" role="form" onsubmit="totp_activate(); return false;">
<div class="form-group">
<label for="inputTOTP" class="control-label">Activation Code</label>
<p><input class="form-control" id="inputTOTP" placeholder="enter 6-digit code"></p>
<p><input type="submit" class="btn btn-primary" value="Activate"></p>
</div>
</form>
</div>
</div>
<p>When using TOTP 2FA, your password becomes your previous plain password plus a space plus the code generated by your TOTP device.</p>
<script>
function show_2fa() {
$('#2fa_current').text('loading...');
api(
"/me/2fa",
"GET",
{
},
function(response) {
$('#2fa_current').text(response.method);
});
}
var secret = null;
function totp_initialize() {
api(
"/me/2fa/totp/initialize",
"POST",
{
},
function(response) {
$('#totp_qr_code').attr('src', 'data:image/png;base64,' + response.qr);
$('#totp-form').fadeIn();
secret = response.secret;
});
}
function totp_activate() {
api(
"/me/2fa/totp/activate",
"POST",
{
"secret": secret,
"code": $('#inputTOTP').val()
},
function(response) {
show_modal_error("Two-Factor Authentication", $("<pre/>").text(response.message));
if (response.status == "OK")
$('#totp-form').fadeOut();
});
}
</script>

View File

@ -115,6 +115,12 @@
</li> </li>
<li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li> <li><a href="#sync_guide" onclick="return show_panel(this);">Contacts/Calendar</a></li>
<li><a href="#web" onclick="return show_panel(this);">Web</a></li> <li><a href="#web" onclick="return show_panel(this);">Web</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">You <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#2fa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
</ul>
</li>
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
<li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li> <li><a href="#" onclick="do_logout(); return false;" style="color: white">Log out?</a></li>
@ -168,6 +174,10 @@
{% include "ssl.html" %} {% include "ssl.html" %}
</div> </div>
<div id="panel_2fa" class="admin_panel">
{% include "2fa.html" %}
</div>
<hr> <hr>
<footer> <footer>

View File

@ -5,6 +5,9 @@ source setup/functions.sh
apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil apt_install python3-flask links duplicity libyaml-dev python3-dnspython python3-dateutil
hide_output pip3 install rtyaml hide_output pip3 install rtyaml
# For two-factor authentication, the management server uses:
hide_output pip3 install git+https://github.com/mail-in-a-box/python-oath qrcode pillow
# Create a backup directory and a random key for encrypting backups. # Create a backup directory and a random key for encrypting backups.
mkdir -p $STORAGE_ROOT/backup mkdir -p $STORAGE_ROOT/backup
if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then if [ ! -f $STORAGE_ROOT/backup/secret_key.txt ]; then