mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2025-04-04 00:17:06 +00:00
Merge remote-tracking branch 'fspoettel/admin-panel-2fa' into totp
# Conflicts: # management/auth.py # management/daemon.py # setup/mail-users.sh # setup/management.sh # setup/migrate.py
This commit is contained in:
commit
24ae913d68
@ -8,7 +8,7 @@ info:
|
||||
This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3).
|
||||
([View the full HTTP specification](https://raw.githubusercontent.com/mail-in-a-box/mailinabox/api-spec/api/mailinabox.yml).)
|
||||
|
||||
All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).
|
||||
All endpoints are relative to `https://{host}/admin` and are secured with [`Basic Access` authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). If you have multi-factor authentication enabled, authentication with a `user:password` combination will fail unless a valid OTP is supplied via the `x-auth-token` header. Authentication via a `user:user_key` pair is possible without the header being present.
|
||||
contact:
|
||||
name: Mail-in-a-Box support
|
||||
url: https://mailinabox.email/
|
||||
@ -46,6 +46,9 @@ tags:
|
||||
- name: Web
|
||||
description: |
|
||||
Static web hosting operations, which include getting domain information and updating domain root directories.
|
||||
- name: MFA
|
||||
description: |
|
||||
Manage multi-factor authentication schemes. Currently, only TOTP is supported.
|
||||
- name: System
|
||||
description: |
|
||||
System operations, which include system status checks, new version checks
|
||||
@ -1662,6 +1665,95 @@ paths:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/mfa/status:
|
||||
get:
|
||||
tags:
|
||||
- MFA
|
||||
summary: Retrieve MFA status
|
||||
description: Retrieves which type of MFA is used and configuration
|
||||
operationId: mfaStatus
|
||||
x-codeSamples:
|
||||
- lang: curl
|
||||
source: |
|
||||
curl -X GET "https://{host}/admin/mfa/status" \
|
||||
-u "<email>:<password>"
|
||||
responses:
|
||||
200:
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MfaStatusResponse'
|
||||
403:
|
||||
description: Forbidden
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/mfa/totp/enable:
|
||||
post:
|
||||
tags:
|
||||
- MFA
|
||||
summary: Enable TOTP authentication
|
||||
description: Enables TOTP authentication for the currently logged-in admin user
|
||||
operationId: mfaTotpEnable
|
||||
x-codeSamples:
|
||||
- lang: curl
|
||||
source: |
|
||||
curl -X POST "https://{host}/admin/mfa/totp/enable" \
|
||||
-d "code=123456" \
|
||||
-d "secret=<string>" \
|
||||
-u "<email>:<password>"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MfaEnableRequest'
|
||||
responses:
|
||||
200:
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MfaEnableSuccessResponse'
|
||||
400:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MfaEnableBadRequestResponse'
|
||||
403:
|
||||
description: Forbidden
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/mfa/totp/disable:
|
||||
post:
|
||||
tags:
|
||||
- MFA
|
||||
summary: Disable TOTP authentication
|
||||
description: Disable TOTP authentication for the currently logged-in admin user
|
||||
operationId: mfaTotpDisable
|
||||
x-codeSamples:
|
||||
- lang: curl
|
||||
source: |
|
||||
curl -X POST "https://{host}/admin/mfa/totp/disable" \
|
||||
-u "<email>:<user_key>"
|
||||
responses:
|
||||
200:
|
||||
description: Successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MfaDisableSuccessResponse'
|
||||
403:
|
||||
description: Forbidden
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
components:
|
||||
securitySchemes:
|
||||
basicAuth:
|
||||
@ -2529,3 +2621,37 @@ components:
|
||||
type: string
|
||||
example: web updated
|
||||
description: Web update response.
|
||||
MfaStatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
example: totp
|
||||
nullable: true
|
||||
totp_secret:
|
||||
type: string
|
||||
nullable: true
|
||||
totp_qr:
|
||||
type: string
|
||||
nullable: true
|
||||
MfaEnableRequest:
|
||||
type: object
|
||||
required:
|
||||
- secret
|
||||
- code
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
MfaEnableSuccessResponse:
|
||||
type: object
|
||||
MfaEnableBadRequestResponse:
|
||||
type: object
|
||||
required:
|
||||
- error
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
MfaDisableSuccessResponse:
|
||||
type: object
|
35
conf/mfa-totp.schema
Normal file
35
conf/mfa-totp.schema
Normal file
@ -0,0 +1,35 @@
|
||||
#
|
||||
# MiaB-LDAP's directory schema for Time-based one time passwords (TOTP)
|
||||
#
|
||||
# MiaB LDAP UUID(v4): 7392cdda-5ec8-431f-9936-0000273c0167
|
||||
# or: 1939000794.24264.17183.39222.658243943
|
||||
#
|
||||
|
||||
objectIdentifier MiabLDAProot 2.25.1939000794.24264.17183.39222.658243943
|
||||
|
||||
objectIdentifier MiabLDAPmfa MiabLDAProot:1
|
||||
objectIdentifier MiabLDAPmfaAttributeType MiabLDAPmfa:3
|
||||
objectIdentifier MiabLDAPmfaObjectClass MiabLDAPmfa:4
|
||||
|
||||
# secret consists of base32 characters (see RFC 4648)
|
||||
attributetype ( MiabLDAPmfaAttributeType:1
|
||||
DESC 'TOTP secret'
|
||||
NAME 'totpSecret'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
EQUALITY caseExactIA5Match )
|
||||
|
||||
# tokens are a base-10 string of N digits - set the syntax to the string
|
||||
# representation of a decimal number
|
||||
attributetype ( MiabLDAPmfaAttributeType:2
|
||||
DESC 'TOTP last token used'
|
||||
NAME 'totpMruToken'
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
EQUALITY caseExactIA5Match )
|
||||
|
||||
objectClass ( MiabLDAPmfaObjectClass:3
|
||||
NAME 'totpUser'
|
||||
DESC 'MiaB-LDAP User TOTP settings'
|
||||
SUP top
|
||||
AUXILIARY
|
||||
MUST ( totpSecret )
|
||||
MAY ( totpMruToken ) )
|
@ -2,7 +2,7 @@ import base64, os, os.path, hmac
|
||||
|
||||
from flask import make_response
|
||||
|
||||
import utils
|
||||
import utils, totp
|
||||
from mailconfig import validate_login, get_mail_password, get_mail_user_privileges
|
||||
|
||||
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
|
||||
@ -76,8 +76,19 @@ class KeyAuthService:
|
||||
return (None, ["admin"])
|
||||
else:
|
||||
# The user is trying to log in with a username and user-specific
|
||||
# API key or password. Raises or returns privs.
|
||||
return (username, self.get_user_credentials(username, password, env))
|
||||
# API key or password. Raises or returns privs and an indicator
|
||||
# whether the user is using their password or a user-specific API-key.
|
||||
privs, is_user_key = self.get_user_credentials(username, password, env)
|
||||
|
||||
# If the user is using their API key to login, 2FA has been passed before
|
||||
if is_user_key:
|
||||
return (username, privs)
|
||||
|
||||
totp_strategy = totp.TOTPStrategy(email=username)
|
||||
# this will raise `totp.MissingTokenError` or `totp.BadTokenError` for bad requests
|
||||
totp_strategy.validate_request(request, env)
|
||||
|
||||
return (username, privs)
|
||||
|
||||
def get_user_credentials(self, email, pw, env):
|
||||
# Validate a user's credentials. On success returns a list of
|
||||
@ -88,11 +99,13 @@ class KeyAuthService:
|
||||
if email == "" or pw == "":
|
||||
raise ValueError("Enter an email address and password.")
|
||||
|
||||
is_user_key = False
|
||||
|
||||
# The password might be a user-specific API key. create_user_key raises
|
||||
# a ValueError if the user does not exist.
|
||||
if hmac.compare_digest(self.create_user_key(email, env), pw):
|
||||
# OK.
|
||||
pass
|
||||
is_user_key = True
|
||||
else:
|
||||
# Get the hashed password of the user. Raise a ValueError if the
|
||||
# email address does not correspond to a user.
|
||||
@ -107,7 +120,7 @@ class KeyAuthService:
|
||||
if isinstance(privs, tuple): raise ValueError(privs[0])
|
||||
|
||||
# Return a list of privileges.
|
||||
return privs
|
||||
return (privs, is_user_key)
|
||||
|
||||
def create_user_key(self, email, env):
|
||||
# Store an HMAC with the client. The hashed message of the HMAC will be the user's
|
||||
|
@ -192,7 +192,7 @@ class LdapConnection(ldap3.Connection):
|
||||
# have values for each attribute in `attrs_to_update`
|
||||
# attrs_to_update: an array of attribute names to update
|
||||
# objectClasses: a list of object classes for a new entry
|
||||
# values: a dict of attributes and values for a new entry
|
||||
# values: a dict of attributes and values for a new or modified entry
|
||||
if existing_record:
|
||||
# modify existing
|
||||
changes = {}
|
||||
|
@ -1,14 +1,15 @@
|
||||
import os, os.path, re, json, time
|
||||
import subprocess
|
||||
import multiprocessing.pool, subprocess
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
|
||||
|
||||
import auth, utils, multiprocessing.pool
|
||||
import auth, utils, totp
|
||||
from mailconfig import get_mail_users, get_mail_users_ex, get_admins, add_mail_user, set_mail_password, set_mail_display_name, remove_mail_user
|
||||
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_mfa_state, create_totp_credential, delete_totp_credential
|
||||
|
||||
env = utils.load_environment()
|
||||
|
||||
@ -37,15 +38,22 @@ def authorized_personnel_only(viewfunc):
|
||||
def newview(*args, **kwargs):
|
||||
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
||||
error = None
|
||||
privs = []
|
||||
|
||||
try:
|
||||
email, privs = auth_service.authenticate(request, env)
|
||||
except ValueError as e:
|
||||
# Authentication failed.
|
||||
privs = []
|
||||
error = "Incorrect username or password"
|
||||
|
||||
except totp.MissingTokenError as e:
|
||||
error = str(e)
|
||||
except totp.BadTokenError as e:
|
||||
# Write a line in the log recording the failed login
|
||||
log_failed_login(request)
|
||||
error = str(e)
|
||||
except ValueError as e:
|
||||
# Write a line in the log recording the failed login
|
||||
log_failed_login(request)
|
||||
# Authentication failed.
|
||||
error = "Incorrect username or password"
|
||||
|
||||
# Authorized to access an API view?
|
||||
if "admin" in privs:
|
||||
@ -83,8 +91,8 @@ def authorized_personnel_only(viewfunc):
|
||||
def unauthorized(error):
|
||||
return auth_service.make_unauthorized_response()
|
||||
|
||||
def json_response(data):
|
||||
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json')
|
||||
def json_response(data, status=200):
|
||||
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
|
||||
|
||||
###################################
|
||||
|
||||
@ -118,6 +126,20 @@ def me():
|
||||
# Is the caller authorized?
|
||||
try:
|
||||
email, privs = auth_service.authenticate(request, env)
|
||||
except totp.MissingTokenError as e:
|
||||
return json_response({
|
||||
"status": "missing_token",
|
||||
"reason": str(e),
|
||||
})
|
||||
except totp.BadTokenError as e:
|
||||
# Log the failed login
|
||||
log_failed_login(request)
|
||||
|
||||
return json_response({
|
||||
"status": "bad_token",
|
||||
"reason": str(e),
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
# Log the failed login
|
||||
log_failed_login(request)
|
||||
@ -125,7 +147,7 @@ def me():
|
||||
return json_response({
|
||||
"status": "invalid",
|
||||
"reason": "Incorrect username or password",
|
||||
})
|
||||
})
|
||||
|
||||
resp = {
|
||||
"status": "ok",
|
||||
@ -392,6 +414,51 @@ def ssl_provision_certs():
|
||||
requests = provision_certificates(env, limit_domains=None)
|
||||
return json_response({ "requests": requests })
|
||||
|
||||
# multi-factor auth
|
||||
|
||||
@app.route('/mfa/status', methods=['GET'])
|
||||
@authorized_personnel_only
|
||||
def two_factor_auth_get_status():
|
||||
email, _ = auth_service.authenticate(request, env)
|
||||
|
||||
mfa_state = get_mfa_state(email, env)
|
||||
|
||||
if mfa_state['type'] == 'totp':
|
||||
return json_response({ "type": 'totp' })
|
||||
|
||||
secret = totp.get_secret()
|
||||
secret_url = totp.get_otp_uri(secret, email)
|
||||
secret_qr = totp.get_qr_code(secret_url)
|
||||
|
||||
return json_response({
|
||||
"type": None,
|
||||
"totp_secret": secret,
|
||||
"totp_qr": secret_qr
|
||||
})
|
||||
|
||||
@app.route('/mfa/totp/enable', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def totp_post_enable():
|
||||
email, _ = auth_service.authenticate(request, env)
|
||||
|
||||
secret = request.form.get('secret')
|
||||
token = request.form.get('token')
|
||||
|
||||
if type(secret) != str or type(token) != str or len(token) != 6 or len(secret) != 32:
|
||||
return json_response({ "error": 'bad_input' }, 400)
|
||||
|
||||
if totp.validate(secret, token):
|
||||
create_totp_credential(email, secret, token, env)
|
||||
return json_response({})
|
||||
|
||||
return json_response({ "error": 'token_mismatch' }, 400)
|
||||
|
||||
@app.route('/mfa/totp/disable', methods=['POST'])
|
||||
@authorized_personnel_only
|
||||
def totp_post_disable():
|
||||
email, _ = auth_service.authenticate(request, env)
|
||||
delete_totp_credential(email, env)
|
||||
return json_response({})
|
||||
|
||||
# WEB
|
||||
|
||||
|
@ -1129,6 +1129,76 @@ def get_required_aliases(env):
|
||||
|
||||
return aliases
|
||||
|
||||
# multi-factor auth
|
||||
|
||||
def get_mfa_state(email, env):
|
||||
# find the user
|
||||
conn = open_database(env)
|
||||
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken'], conn)
|
||||
if user is None or 'totpUser' not in user['objectClass']:
|
||||
return { 'type': None }
|
||||
|
||||
secret = user['totpSecret'][0]
|
||||
mru_token = None
|
||||
if len(user['totpMruToken'])>0:
|
||||
mru_token = user['totpMruToken'][0]
|
||||
|
||||
return {
|
||||
'type': 'totp',
|
||||
'secret': secret,
|
||||
'mru_token': '' if mru_token is None else mru_token
|
||||
}
|
||||
|
||||
def create_totp_credential(email, secret, token, env):
|
||||
validate_totp_secret(secret)
|
||||
conn = open_database(env)
|
||||
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken'], conn)
|
||||
if user is None:
|
||||
return ("That's not a user (%s)." % email, 400)
|
||||
|
||||
attrs = {
|
||||
"totpSecret": secret,
|
||||
"totpMruToken": token
|
||||
}
|
||||
if 'totpUser' not in user['objectClass']:
|
||||
attrs['objectClass'] = user['objectClass'].copy()
|
||||
attrs['objectClass'].append('totpUser')
|
||||
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
|
||||
return "OK"
|
||||
|
||||
def set_mru_totp_code(email, token, env):
|
||||
conn = open_database(env)
|
||||
user = find_mail_user(env, email, ['objectClass','totpMruToken'], conn)
|
||||
if user is None:
|
||||
return ("That's not a user (%s)." % email, 400)
|
||||
|
||||
if 'totpUser' not in user['objectClass']:
|
||||
return ("User (%s) not configured for TOTP" % email, 400)
|
||||
|
||||
attrs = {
|
||||
"totpMruToken": token
|
||||
}
|
||||
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
|
||||
return "OK"
|
||||
|
||||
def delete_totp_credential(email, env):
|
||||
conn = open_database(env)
|
||||
user = find_mail_user(env, email, ['objectClass','totpSecret','totpMruToken'], conn)
|
||||
if user is None:
|
||||
return ("That's not a user (%s)." % email, 400)
|
||||
|
||||
if 'totpUser' not in user['objectClass']:
|
||||
return "OK"
|
||||
|
||||
attrs = {
|
||||
"totpMruToken": None,
|
||||
"totpSecret": None,
|
||||
"objectClass": user["objectClass"].copy()
|
||||
}
|
||||
attrs["objectClass"].remove("totpUser")
|
||||
conn.add_or_modify(user['dn'], user, attrs.keys(), None, attrs)
|
||||
return "OK"
|
||||
|
||||
def kick(env, mail_result=None):
|
||||
results = []
|
||||
|
||||
@ -1190,6 +1260,11 @@ def validate_password(pw):
|
||||
if len(pw) < 8:
|
||||
raise ValueError("Passwords must be at least eight characters.")
|
||||
|
||||
def validate_totp_secret(secret):
|
||||
if type(secret) != str or secret.strip() == "":
|
||||
raise ValueError("No secret provided.")
|
||||
if len(secret) != 32:
|
||||
raise ValueError("Secret should be a 32 characters base32 string")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
@ -93,6 +93,7 @@
|
||||
<li class="dropdown-header">Advanced Pages</li>
|
||||
<li><a href="#custom_dns" onclick="return show_panel(this);">Custom DNS</a></li>
|
||||
<li><a href="#external_dns" onclick="return show_panel(this);">External DNS</a></li>
|
||||
<li><a href="#two_factor_auth" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
|
||||
<li><a href="/admin/munin" target="_blank">Munin Monitoring</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
@ -131,6 +132,10 @@
|
||||
{% include "custom-dns.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_two_factor_auth" class="admin_panel">
|
||||
{% include "two-factor-auth.html" %}
|
||||
</div>
|
||||
|
||||
<div id="panel_login" class="admin_panel">
|
||||
{% include "login.html" %}
|
||||
</div>
|
||||
@ -292,7 +297,7 @@ function ajax_with_indicator(options) {
|
||||
}
|
||||
|
||||
var api_credentials = ["", ""];
|
||||
function api(url, method, data, callback, callback_error) {
|
||||
function api(url, method, data, callback, callback_error, headers) {
|
||||
// from http://www.webtoolkit.info/javascript-base64.html
|
||||
function base64encode(input) {
|
||||
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
@ -330,7 +335,7 @@ function api(url, method, data, callback, callback_error) {
|
||||
method: method,
|
||||
cache: false,
|
||||
data: data,
|
||||
|
||||
headers: headers,
|
||||
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
|
||||
processData: typeof data != "string",
|
||||
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
|
||||
|
@ -1,4 +1,29 @@
|
||||
<h1 style="margin: 1em; text-align: center">{{hostname}}</h1>
|
||||
<style>
|
||||
.title {
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login {
|
||||
margin: 0 auto;
|
||||
max-width: 32em;
|
||||
}
|
||||
|
||||
.login #loginOtp {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loginForm.is-twofactor #loginOtp {
|
||||
display: block
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1 class="title">{{hostname}}</h1>
|
||||
|
||||
{% if no_users_exist or no_admins_exist %}
|
||||
<div class="row">
|
||||
@ -20,10 +45,10 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin: 2em; text-align: center;">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
<p class="subtitle">Log in here for your Mail-in-a-Box control panel.</p>
|
||||
|
||||
<div style="margin: 0 auto; max-width: 32em;">
|
||||
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||
<div class="login">
|
||||
<form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail3" class="col-sm-3 control-label">Email</label>
|
||||
<div class="col-sm-9">
|
||||
@ -45,6 +70,12 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="loginOtp">
|
||||
<div class="col-sm-offset-3 col-sm-9">
|
||||
<label for="loginOtpInput" class="control-label">Two-Factor Code</label>
|
||||
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-3 col-sm-9">
|
||||
<button type="submit" class="btn btn-default">Sign in</button>
|
||||
@ -53,15 +84,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function do_login() {
|
||||
if ($('#loginEmail').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email address.", function() {
|
||||
$('#loginEmail').focus();
|
||||
$('#loginEmail').focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($('#loginPassword').val() == "") {
|
||||
show_modal_error("Login Failed", "Enter your email password.", function() {
|
||||
$('#loginPassword').focus();
|
||||
@ -75,17 +106,24 @@ function do_login() {
|
||||
api(
|
||||
"/me",
|
||||
"GET",
|
||||
{ },
|
||||
function(response){
|
||||
{},
|
||||
function(response) {
|
||||
// This API call always succeeds. It returns a JSON object indicating
|
||||
// whether the request was authenticated or not.
|
||||
if (response.status != "ok") {
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
if (response.status != 'ok') {
|
||||
if (response.status === 'missing_token' && !$('#loginForm').hasClass('is-twofactor')) {
|
||||
$('#loginForm').addClass('is-twofactor');
|
||||
setTimeout(() => {
|
||||
$('#loginOtpInput').focus();
|
||||
});
|
||||
} else {
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
// Show why the login failed.
|
||||
show_modal_error("Login Failed", response.reason)
|
||||
|
||||
// Reset any saved credentials.
|
||||
do_logout();
|
||||
}
|
||||
} else if (!("api_key" in response)) {
|
||||
// Login succeeded but user might not be authorized!
|
||||
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||
@ -102,6 +140,8 @@ function do_login() {
|
||||
// Try to wipe the username/password information.
|
||||
$('#loginEmail').val('');
|
||||
$('#loginPassword').val('');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
|
||||
// Remember the credentials.
|
||||
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
|
||||
@ -119,7 +159,11 @@ function do_login() {
|
||||
// which confuses the loading indicator.
|
||||
setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
|
||||
}
|
||||
})
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
'x-auth-token': $('#loginOtpInput').val()
|
||||
});
|
||||
}
|
||||
|
||||
function do_logout() {
|
||||
@ -132,6 +176,8 @@ function do_logout() {
|
||||
}
|
||||
|
||||
function show_login() {
|
||||
$('#loginForm').removeClass('is-twofactor');
|
||||
$('#loginOtpInput').val('');
|
||||
$('#loginEmail,#loginPassword').each(function() {
|
||||
var input = $(this);
|
||||
if (!$.trim(input.val())) {
|
||||
|
220
management/templates/two-factor-auth.html
Normal file
220
management/templates/two-factor-auth.html
Normal file
@ -0,0 +1,220 @@
|
||||
<style>
|
||||
.twofactor #totp-setup,
|
||||
.twofactor #disable-2fa,
|
||||
.twofactor #output-2fa {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.loaded .loading-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #disable-2fa,
|
||||
.twofactor.enabled #totp-setup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twofactor.disabled #totp-setup,
|
||||
.twofactor.enabled #disable-2fa {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.twofactor #totp-setup-qr img {
|
||||
display: block;
|
||||
width: 256px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.twofactor #output-2fa.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
|
||||
<div class="twofactor">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
|
||||
<form id="totp-setup">
|
||||
<div class="form-group">
|
||||
<h3>Setup</h3>
|
||||
<p>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.</p>
|
||||
<p>1. Scan the QR code or enter the secret into an authenticator app (e.g. Google Authenticator)</p>
|
||||
<div id="totp-setup-qr"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="otp">2. Enter the code displayed in the Authenticator app</label>
|
||||
<input type="text" id="totp-setup-token" class="form-control" placeholder="6-digit code" />
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="totp-setup-secret" />
|
||||
|
||||
<div class="form-group">
|
||||
<button id="totp-setup-submit" disabled type="submit" class="btn">Enable two factor authentication</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="disable-2fa">
|
||||
<div class="form-group">
|
||||
<p>Two factor authentication is active.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">Disable two factor authentication</button>
|
||||
</form>
|
||||
|
||||
<div id="output-2fa" class="panel panel-danger">
|
||||
<div class="panel-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var el = {
|
||||
disableForm: document.getElementById('disable-2fa'),
|
||||
output: document.getElementById('output-2fa'),
|
||||
totpSetupForm: document.getElementById('totp-setup'),
|
||||
totpSetupToken: document.getElementById('totp-setup-token'),
|
||||
totpSetupSecret: document.getElementById('totp-setup-secret'),
|
||||
totpQr: document.getElementById('totp-setup-qr'),
|
||||
totpSetupSubmit: document.querySelector('#totp-setup-submit'),
|
||||
wrapper: document.querySelector('.twofactor')
|
||||
}
|
||||
|
||||
function update_setup_disabled(evt) {
|
||||
var val = evt.target.value.trim();
|
||||
|
||||
if (
|
||||
typeof val !== 'string' ||
|
||||
typeof el.totpSetupSecret.value !== 'string' ||
|
||||
val.length !== 6 ||
|
||||
el.totpSetupSecret.value.length !== 32 ||
|
||||
!(/^\+?\d+$/.test(val))
|
||||
) {
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
} else {
|
||||
el.totpSetupSubmit.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function render_totp_setup(res) {
|
||||
function render_qr_code(encoded) {
|
||||
var img = document.createElement('img');
|
||||
img.src = encoded;
|
||||
|
||||
var code = document.createElement('div');
|
||||
code.innerHTML = `Secret: ${res.totp_secret}`;
|
||||
|
||||
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.wrapper.classList.add('disabled');
|
||||
}
|
||||
|
||||
function render_disable() {
|
||||
el.disableForm.addEventListener('submit', do_disable);
|
||||
el.wrapper.classList.add('enabled');
|
||||
}
|
||||
|
||||
function hide_error() {
|
||||
el.output.querySelector('.panel-body').innerHTML = '';
|
||||
el.output.classList.remove('visible');
|
||||
}
|
||||
|
||||
function render_error(msg) {
|
||||
el.output.querySelector('.panel-body').innerHTML = msg;
|
||||
el.output.classList.add('visible');
|
||||
}
|
||||
|
||||
function reset_view() {
|
||||
el.wrapper.classList.remove('loaded', 'disabled', 'enabled');
|
||||
|
||||
el.disableForm.removeEventListener('submit', do_disable);
|
||||
|
||||
hide_error();
|
||||
|
||||
el.totpSetupForm.reset();
|
||||
el.totpSetupForm.removeEventListener('submit', do_enable_totp);
|
||||
|
||||
el.totpSetupSecret.setAttribute('value', '');
|
||||
el.totpSetupToken.removeEventListener('input', update_setup_disabled);
|
||||
|
||||
el.totpSetupSubmit.setAttribute('disabled', '');
|
||||
el.totpQr.innerHTML = '';
|
||||
}
|
||||
|
||||
function show_two_factor_auth() {
|
||||
reset_view();
|
||||
|
||||
api(
|
||||
'/mfa/status',
|
||||
'GET',
|
||||
{},
|
||||
function(res) {
|
||||
el.wrapper.classList.add('loaded');
|
||||
var isTotpEnabled = res.type === 'totp'
|
||||
return isTotpEnabled ? render_disable(res) : render_totp_setup(res);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function do_disable(evt) {
|
||||
evt.preventDefault();
|
||||
hide_error();
|
||||
|
||||
api(
|
||||
'/mfa/totp/disable',
|
||||
'POST',
|
||||
{},
|
||||
function() { show_two_factor_auth(); }
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function do_enable_totp(evt) {
|
||||
evt.preventDefault();
|
||||
hide_error();
|
||||
|
||||
api(
|
||||
'/mfa/totp/enable',
|
||||
'POST',
|
||||
{
|
||||
token: $(el.totpSetupToken).val(),
|
||||
secret: $(el.totpSetupSecret).val()
|
||||
},
|
||||
function(res) { show_two_factor_auth(); },
|
||||
function(res) {
|
||||
var errorMessage = 'Something went wrong.';
|
||||
var parsed;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(res);
|
||||
} catch (err) {
|
||||
return render_error(errorMessage);
|
||||
}
|
||||
|
||||
var error = parsed && parsed.error
|
||||
? parsed.error
|
||||
: null;
|
||||
|
||||
if (error === 'token_mismatch') {
|
||||
errorMessage = 'Code does not match.';
|
||||
} else if (error === 'bad_input') {
|
||||
errorMessage = 'Received request with malformed data.';
|
||||
}
|
||||
|
||||
render_error(errorMessage);
|
||||
}
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
</script>
|
72
management/totp.py
Normal file
72
management/totp.py
Normal file
@ -0,0 +1,72 @@
|
||||
import base64
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import pyotp
|
||||
import qrcode
|
||||
from mailconfig import get_mfa_state, set_mru_totp_code
|
||||
|
||||
def get_secret():
|
||||
return base64.b32encode(os.urandom(20)).decode('utf-8')
|
||||
|
||||
def get_otp_uri(secret, email):
|
||||
return pyotp.TOTP(secret).provisioning_uri(
|
||||
name=email,
|
||||
issuer_name='mailinabox'
|
||||
)
|
||||
|
||||
def get_qr_code(data):
|
||||
qr = qrcode.make(data)
|
||||
byte_arr = io.BytesIO()
|
||||
qr.save(byte_arr, format='PNG')
|
||||
|
||||
encoded = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
|
||||
return 'data:image/png;base64,{}'.format(encoded)
|
||||
|
||||
def validate(secret, token):
|
||||
"""
|
||||
@see https://tools.ietf.org/html/rfc6238#section-4
|
||||
@see https://tools.ietf.org/html/rfc4226#section-5.4
|
||||
"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(token, valid_window=1)
|
||||
|
||||
class MissingTokenError(ValueError):
|
||||
pass
|
||||
|
||||
class BadTokenError(ValueError):
|
||||
pass
|
||||
|
||||
class TOTPStrategy():
|
||||
def __init__(self, email):
|
||||
self.type = 'totp'
|
||||
self.email = email
|
||||
|
||||
def store_successful_login(self, token, env):
|
||||
return set_mru_totp_code(self.email, token, env)
|
||||
|
||||
def validate_request(self, request, env):
|
||||
mfa_state = get_mfa_state(self.email, env)
|
||||
|
||||
# 2FA is not enabled, we can skip further checks
|
||||
if mfa_state['type'] != 'totp':
|
||||
return True
|
||||
|
||||
# If 2FA is enabled, raise if:
|
||||
# 1. no token is provided via `x-auth-token`
|
||||
# 2. a previously supplied token is used (to counter replay attacks)
|
||||
# 3. the token is invalid
|
||||
# in that case, we need to raise and indicate to the client to supply a TOTP
|
||||
token_header = request.headers.get('x-auth-token')
|
||||
|
||||
if not token_header:
|
||||
raise MissingTokenError("Two factor code missing (no x-auth-token supplied)")
|
||||
|
||||
# TODO: Should a token replay be handled as its own error?
|
||||
if hmac.compare_digest(token_header, mfa_state['mru_token']) or validate(mfa_state['secret'], token_header) != True:
|
||||
raise BadTokenError("Two factor code incorrect")
|
||||
|
||||
self.store_successful_login(token_header, env)
|
||||
return True
|
@ -374,6 +374,20 @@ add_schemas() {
|
||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f "$ldif" >/dev/null
|
||||
rm -f "$ldif"
|
||||
fi
|
||||
|
||||
# apply the mfa-totp schema
|
||||
# this adds the totpUser class to store the totp secret
|
||||
local schema="mfa-totp.schema"
|
||||
local cn="mfa-totp"
|
||||
get_attribute "cn=schema,cn=config" "(&(cn={*}$cn)(objectClass=olcSchemaConfig))" "cn"
|
||||
if [ -z "$ATTR_DN" ]; then
|
||||
local ldif="/tmp/$cn.$$.ldif"
|
||||
schema_to_ldif "$schema" "$ldif" "$cn"
|
||||
say_verbose "Adding '$cn' schema"
|
||||
[ $verbose -gt 1 ] && cat "$ldif"
|
||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f "$ldif" >/dev/null
|
||||
rm -f "$ldif"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@ -560,16 +574,18 @@ apply_access_control() {
|
||||
# Permission restrictions:
|
||||
# service accounts (except management):
|
||||
# can bind but not change passwords, including their own
|
||||
# can read all attributes of all users but not userPassword
|
||||
# can read all attributes of all users but not userPassword,
|
||||
# totpSecret, or totpMruToken
|
||||
# can read config subtree (permitted-senders, domains)
|
||||
# no access to services subtree, except their own dn
|
||||
# management service account:
|
||||
# can read and change password and shadowLastChange
|
||||
# can read and change password, shadowLastChange, and totpSecret
|
||||
# all other service account permissions are the same
|
||||
# users:
|
||||
# can bind and change their own password
|
||||
# can read and change their own shadowLastChange
|
||||
# can read attributess of all users except mailaccess
|
||||
# cannot read or modify totpSecret, totpMruToken
|
||||
# can read attributess of other users except mailaccess, totpSecret, totpMruToken
|
||||
# no access to config subtree
|
||||
# no access to services subtree
|
||||
#
|
||||
@ -591,6 +607,10 @@ olcAccess: to attrs=userPassword
|
||||
by self =wx
|
||||
by anonymous auth
|
||||
by * none
|
||||
olcAccess: to attrs=totpSecret,totpMruToken
|
||||
by dn.exact="cn=management,${LDAP_SERVICES_BASE}" write
|
||||
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" read
|
||||
by * none
|
||||
olcAccess: to attrs=shadowLastChange
|
||||
by self write
|
||||
by dn.exact="cn=management,${LDAP_SERVICES_BASE}" write
|
||||
|
@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
|
||||
hide_output $venv/bin/pip install --upgrade \
|
||||
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
|
||||
flask dnspython python-dateutil \
|
||||
qrcode[pil] pyotp \
|
||||
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver ldap3
|
||||
|
||||
# CONFIGURATION
|
||||
|
@ -241,7 +241,6 @@ def migration_13(env):
|
||||
ldap.unbind()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_current_migration():
|
||||
ver = 0
|
||||
while True:
|
||||
|
Loading…
Reference in New Issue
Block a user