mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-23 02:27:05 +00:00
TOTP two-factor authentication
This commit is contained in:
parent
1f0345fe0e
commit
6c843fc92e
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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)
|
||||||
|
79
management/templates/2fa.html
Normal file
79
management/templates/2fa.html
Normal 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’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.</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>
|
@ -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>
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
source setup/functions.sh
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user