1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2024-11-23 02:27:05 +00:00
This commit is contained in:
Ashiq5 2020-11-06 01:06:02 +06:00
commit fe0e775fcc
19 changed files with 974 additions and 217 deletions

View File

@ -1,6 +1,23 @@
CHANGELOG CHANGELOG
========= =========
IN DEVELOPMENT
--------------
Software updates:
* Upgraded Nextcloud from 17.0.6 to 20.0.1 (with Contacts from 3.3.0 to 3.4.1 and Calendar from 2.0.3 to 2.1.2)
* Upgraded Roundcube to version 1.4.9.
Mail:
* The MTA-STA max_age value was increased to the normal one week.
Control Panel:
* Two-factor authentication can now be enabled for logins to the control panel. However, keep in mind that many online services (including domain name registrars, cloud server providers, and TLS certificate providers) may allow an attacker to take over your account or issue a fraudulent TLS certificate with only access to your email address, and this new two-factor authentication does not protect access to your inbox. It therefore remains very important that user accounts with administrative email addresses have strong passwords.
* TLS certificate expiry dates are now shown in ISO8601 format for clarity.
v0.50 (September 25, 2020) v0.50 (September 25, 2020)
-------------------------- --------------------------

View File

@ -8,7 +8,7 @@ info:
This API is documented in [**OpenAPI format**](http://spec.openapis.org/oas/v3.0.3). 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).) ([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: contact:
name: Mail-in-a-Box support name: Mail-in-a-Box support
url: https://mailinabox.email/ url: https://mailinabox.email/
@ -46,6 +46,9 @@ tags:
- name: Web - name: Web
description: | description: |
Static web hosting operations, which include getting domain information and updating domain root directories. 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 - name: System
description: | description: |
System operations, which include system status checks, new version checks System operations, which include system status checks, new version checks
@ -1662,6 +1665,101 @@ paths:
text/html: text/html:
schema: schema:
type: string type: string
/mfa/status:
post:
tags:
- MFA
summary: Retrieve MFA status for you or another user
description: Retrieves which type of MFA is used and configuration
operationId: mfaStatus
x-codeSamples:
- lang: curl
source: |
curl -X POST "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:
text/html:
schema:
$ref: '#/components/schemas/MfaEnableSuccessResponse'
400:
description: Bad request
content:
text/html:
schema:
type: string
403:
description: Forbidden
content:
text/html:
schema:
type: string
/mfa/disable:
post:
tags:
- MFA
summary: Disable multi-factor authentication for you or another user
description: Disables multi-factor authentication for the currently logged-in admin user or another user if a 'user' parameter is submitted. Either disables all multi-factor authentication methods or the method corresponding to the optional property `mfa_id`.
operationId: mfaTotpDisable
requestBody:
required: false
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/MfaDisableRequest'
x-codeSamples:
- lang: curl
source: |
curl -X POST "https://{host}/admin/mfa/totp/disable" \
-u "<email>:<user_key>"
responses:
200:
description: Successful operation
content:
text/html:
schema:
$ref: '#/components/schemas/MfaDisableSuccessResponse'
403:
description: Forbidden
content:
text/html:
schema:
type: string
components: components:
securitySchemes: securitySchemes:
basicAuth: basicAuth:
@ -2529,3 +2627,54 @@ components:
type: string type: string
example: web updated example: web updated
description: Web update response. description: Web update response.
MfaStatusResponse:
type: object
properties:
enabled_mfa:
type: object
properties:
id:
type: string
type:
type: string
label:
type: string
nullable: true
new_mfa:
type: object
properties:
type:
type: string
secret:
type: string
qr_code_base64:
type: string
MfaEnableRequest:
type: object
required:
- secret
- code
properties:
secret:
type: string
code:
type: string
label:
type: string
MfaEnableSuccessResponse:
type: string
MfaEnableBadRequestResponse:
type: object
required:
- error
properties:
error:
type: string
MfaDisableRequest:
type: object
properties:
mfa_id:
type: string
nullable: true
MfaDisableSuccessResponse:
type: string

View File

@ -1,9 +1,10 @@
import base64, os, os.path, hmac import base64, os, os.path, hmac, json
from flask import make_response from flask import make_response
import utils import utils
from mailconfig import get_mail_password, get_mail_user_privileges from mailconfig import get_mail_password, get_mail_user_privileges
from mfa import get_hash_mfa_state, validate_auth_mfa
DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key' DEFAULT_KEY_PATH = '/var/lib/mailinabox/api.key'
DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server' DEFAULT_AUTH_REALM = 'Mail-in-a-Box Management Server'
@ -72,17 +73,19 @@ class KeyAuthService:
if username in (None, ""): if username in (None, ""):
raise ValueError("Authorization header invalid.") raise ValueError("Authorization header invalid.")
elif username == self.key: elif username == self.key:
# The user passed the API key which grants administrative privs. # The user passed the master API key which grants administrative privs.
return (None, ["admin"]) return (None, ["admin"])
else: else:
# The user is trying to log in with a username and user-specific # The user is trying to log in with a username and either a password
# API key or password. Raises or returns privs. # (and possibly a MFA token) or a user-specific API key.
return (username, self.get_user_credentials(username, password, env)) return (username, self.check_user_auth(username, password, request, env))
def get_user_credentials(self, email, pw, env): def check_user_auth(self, email, pw, request, env):
# Validate a user's credentials. On success returns a list of # Validate a user's login email address and password. If MFA is enabled,
# privileges (e.g. [] or ['admin']). On failure raises a ValueError # check the MFA token in the X-Auth-Token header.
# with a login error message. #
# On success returns a list of privileges (e.g. [] or ['admin']). On login
# failure, raises a ValueError with a login error message.
# Sanity check. # Sanity check.
if email == "" or pw == "": if email == "" or pw == "":
@ -112,6 +115,12 @@ class KeyAuthService:
# Login failed. # Login failed.
raise ValueError("Invalid password.") raise ValueError("Invalid password.")
# If MFA is enabled, check that MFA passes.
status, hints = validate_auth_mfa(email, request, env)
if not status:
# Login valid. Hints may have more info.
raise ValueError(",".join(hints))
# Get privileges for authorization. This call should never fail because by this # Get privileges for authorization. This call should never fail because by this
# point we know the email address is a valid user. But on error the call will # point we know the email address is a valid user. But on error the call will
# return a tuple of an error message and an HTTP status code. # return a tuple of an error message and an HTTP status code.
@ -122,16 +131,27 @@ class KeyAuthService:
return privs return privs
def create_user_key(self, email, env): def create_user_key(self, email, env):
# Store an HMAC with the client. The hashed message of the HMAC will be the user's # Create a user API key, which is a shared secret that we can re-generate from
# email address & hashed password and the key will be the master API key. The user of # static information in our database. The shared secret contains the user's
# course has their own email address and password. We assume they do not have the master # email address, current hashed password, and current MFA state, so that the
# API key (unless they are trusted anyway). The HMAC proves that they authenticated # key becomes invalid if any of that information changes.
# with us in some other way to get the HMAC. Including the password means that when #
# a user's password is reset, the HMAC changes and they will correctly need to log # Use an HMAC to generate the API key using our master API key as a key,
# in to the control panel again. This method raises a ValueError if the user does # which also means that the API key becomes invalid when our master API key
# not exist, due to get_mail_password. # changes --- i.e. when this process is restarted.
#
# Raises ValueError via get_mail_password if the user doesn't exist.
# Construct the HMAC message from the user's email address and current password.
msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8") msg = b"AUTH:" + email.encode("utf8") + b" " + get_mail_password(email, env).encode("utf8")
return hmac.new(self.key.encode('ascii'), msg, digestmod="sha256").hexdigest()
# Add to the message the current MFA state, which is a list of MFA information.
# Turn it into a string stably.
msg += b" " + json.dumps(get_hash_mfa_state(email, env), sort_keys=True).encode("utf8")
# Make the HMAC.
hash_key = self.key.encode('ascii')
return hmac.new(hash_key, msg, digestmod="sha256").hexdigest()
def _generate_key(self): def _generate_key(self):
raw_key = os.urandom(32) raw_key = os.urandom(32)

150
management/cli.py Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/python3
#
# This is a command-line script for calling management APIs
# on the Mail-in-a-Box control panel backend. The script
# reads /var/lib/mailinabox/api.key for the backend's
# root API key. This file is readable only by root, so this
# tool can only be used as root.
import sys, getpass, urllib.request, urllib.error, json, re, csv
def mgmt(cmd, data=None, is_json=False):
# The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
try:
response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
if e.code == 401:
try:
print(e.read().decode("utf8"))
except:
pass
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr)
else:
print(e, file=sys.stderr)
sys.exit(1)
resp = response.read().decode('utf8')
if is_json: resp = json.loads(resp)
return resp
def read_password():
while True:
first = getpass.getpass('password: ')
if len(first) < 8:
print("Passwords must be at least eight characters.")
continue
second = getpass.getpass(' (again): ')
if first != second:
print("Passwords not the same. Try again.")
continue
break
return first
def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler()
auth_handler.add_password(
realm='Mail-in-a-Box Management Server',
uri=mgmt_uri,
user=key,
passwd='')
opener = urllib.request.build_opener(auth_handler)
urllib.request.install_opener(opener)
if len(sys.argv) < 2:
print("""Usage:
{cli} user (lists users)
{cli} user add user@domain.com [password]
{cli} user password user@domain.com [password]
{cli} user remove user@domain.com
{cli} user make-admin user@domain.com
{cli} user remove-admin user@domain.com
{cli} user admins (lists admins)
{cli} user mfa show user@domain.com (shows MFA devices for user, if any)
{cli} user mfa disable user@domain.com [id] (disables MFA for user)
{cli} alias (lists aliases)
{cli} alias add incoming.name@domain.com sent.to@other.domain.com
{cli} alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'
{cli} alias remove incoming.name@domain.com
Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.
""".format(
cli="management/cli.py"
))
elif sys.argv[1] == "user" and len(sys.argv) == 2:
# Dump a list of users, one per line. Mark admins with an asterisk.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if user['status'] == 'inactive': continue
print(user['email'], end='')
if "admin" in user['privileges']:
print("*", end='')
print()
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
if len(sys.argv) < 5:
if len(sys.argv) < 4:
email = input("email: ")
else:
email = sys.argv[3]
pw = read_password()
else:
email, pw = sys.argv[3:5]
if sys.argv[2] == "add":
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
elif sys.argv[2] == "password":
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
if sys.argv[2] == "make-admin":
action = "add"
else:
action = "remove"
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
# Dump a list of admin users.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if "admin" in user['privileges']:
print(user['email'])
elif sys.argv[1] == "user" and len(sys.argv) == 5 and sys.argv[2:4] == ["mfa", "show"]:
# Show MFA status for a user.
status = mgmt("/mfa/status", { "user": sys.argv[4] }, is_json=True)
W = csv.writer(sys.stdout)
W.writerow(["id", "type", "label"])
for mfa in status["enabled_mfa"]:
W.writerow([mfa["id"], mfa["type"], mfa["label"]])
elif sys.argv[1] == "user" and len(sys.argv) in (5, 6) and sys.argv[2:4] == ["mfa", "disable"]:
# Disable MFA (all or a particular device) for a user.
print(mgmt("/mfa/disable", { "user": sys.argv[4], "mfa-id": sys.argv[5] if len(sys.argv) == 6 else None }))
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
else:
print("Invalid command-line arguments.")
sys.exit(1)

View File

@ -1,14 +1,15 @@
import os, os.path, re, json, time import os, os.path, re, json, time
import subprocess import multiprocessing.pool, subprocess
from functools import wraps from functools import wraps
from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response from flask import Flask, request, render_template, abort, Response, send_from_directory, make_response
import auth, utils, multiprocessing.pool 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
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
from mfa import get_public_mfa_state, provision_totp, validate_totp_secret, enable_mfa, disable_mfa
env = utils.load_environment() env = utils.load_environment()
@ -35,23 +36,31 @@ app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirna
def authorized_personnel_only(viewfunc): def authorized_personnel_only(viewfunc):
@wraps(viewfunc) @wraps(viewfunc)
def newview(*args, **kwargs): def newview(*args, **kwargs):
# Authenticate the passed credentials, which is either the API key or a username:password pair. # Authenticate the passed credentials, which is either the API key or a username:password pair
# and an optional X-Auth-Token token.
error = None error = None
privs = []
try: try:
email, privs = auth_service.authenticate(request, env) email, privs = auth_service.authenticate(request, env)
except ValueError as e: except ValueError as e:
# Authentication failed.
privs = []
error = "Incorrect username or password"
# Write a line in the log recording the failed login # Write a line in the log recording the failed login
log_failed_login(request) log_failed_login(request)
# Authentication failed.
error = str(e)
# Authorized to access an API view? # Authorized to access an API view?
if "admin" in privs: if "admin" in privs:
# Store the email address of the logged in user so it can be accessed
# from the API methods that affect the calling user.
request.user_email = email
request.user_privs = privs
# Call view func. # Call view func.
return viewfunc(*args, **kwargs) return viewfunc(*args, **kwargs)
elif not error:
if not error:
error = "You are not an administrator." error = "You are not an administrator."
# Not authorized. Return a 401 (send auth) and a prompt to authorize by default. # Not authorized. Return a 401 (send auth) and a prompt to authorize by default.
@ -83,8 +92,8 @@ def authorized_personnel_only(viewfunc):
def unauthorized(error): def unauthorized(error):
return auth_service.make_unauthorized_response() return auth_service.make_unauthorized_response()
def json_response(data): def json_response(data, status=200):
return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=200, mimetype='application/json') return Response(json.dumps(data, indent=2, sort_keys=True)+'\n', status=status, mimetype='application/json')
################################### ###################################
@ -119,12 +128,17 @@ def me():
try: try:
email, privs = auth_service.authenticate(request, env) email, privs = auth_service.authenticate(request, env)
except ValueError as e: except ValueError as e:
# Log the failed login if "missing-totp-token" in str(e):
log_failed_login(request) return json_response({
"status": "missing-totp-token",
return json_response({ "reason": str(e),
"status": "invalid", })
"reason": "Incorrect username or password", else:
# Log the failed login
log_failed_login(request)
return json_response({
"status": "invalid",
"reason": str(e),
}) })
resp = { resp = {
@ -464,6 +478,60 @@ def ssl_provision_certs():
requests = provision_certificates(env, limit_domains=None) requests = provision_certificates(env, limit_domains=None)
return json_response({ "requests": requests }) return json_response({ "requests": requests })
# multi-factor auth
@app.route('/mfa/status', methods=['POST'])
@authorized_personnel_only
def mfa_get_status():
# Anyone accessing this route is an admin, and we permit them to
# see the MFA status for any user if they submit a 'user' form
# field. But we don't include provisioning info since a user can
# only provision for themselves.
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
try:
resp = {
"enabled_mfa": get_public_mfa_state(email, env)
}
if email == request.user_email:
resp.update({
"new_mfa": {
"totp": provision_totp(email, env)
}
})
except ValueError as e:
return (str(e), 400)
return json_response(resp)
@app.route('/mfa/totp/enable', methods=['POST'])
@authorized_personnel_only
def totp_post_enable():
secret = request.form.get('secret')
token = request.form.get('token')
label = request.form.get('label')
if type(token) != str:
return ("Bad Input", 400)
try:
validate_totp_secret(secret)
enable_mfa(request.user_email, "totp", secret, token, label, env)
except ValueError as e:
return (str(e), 400)
return "OK"
@app.route('/mfa/disable', methods=['POST'])
@authorized_personnel_only
def totp_post_disable():
# Anyone accessing this route is an admin, and we permit them to
# disable the MFA status for any user if they submit a 'user' form
# field.
email = request.form.get('user', request.user_email) # user field if given, otherwise the user making the request
try:
result = disable_mfa(email, request.form.get('mfa-id') or None, env) # convert empty string to None
except ValueError as e:
return (str(e), 400)
if result: # success
return "OK"
else: # error
return ("Invalid user or MFA id.", 400)
# WEB # WEB

View File

@ -608,7 +608,6 @@ def validate_password(pw):
if len(pw) < 8: if len(pw) < 8:
raise ValueError("Passwords must be at least eight characters.") raise ValueError("Passwords must be at least eight characters.")
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
if len(sys.argv) > 2 and sys.argv[1] == "validate-email": if len(sys.argv) > 2 and sys.argv[1] == "validate-email":

141
management/mfa.py Normal file
View File

@ -0,0 +1,141 @@
import base64
import hmac
import io
import os
import pyotp
import qrcode
from mailconfig import open_database
def get_user_id(email, c):
c.execute('SELECT id FROM users WHERE email=?', (email,))
r = c.fetchone()
if not r: raise ValueError("User does not exist.")
return r[0]
def get_mfa_state(email, env):
c = open_database(env)
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], "label": r[4] }
for r in c.fetchall()
]
def get_public_mfa_state(email, env):
mfa_state = get_mfa_state(email, env)
return [
{ "id": s["id"], "type": s["type"], "label": s["label"] }
for s in mfa_state
]
def get_hash_mfa_state(email, env):
mfa_state = get_mfa_state(email, env)
return [
{ "id": s["id"], "type": s["type"], "secret": s["secret"] }
for s in mfa_state
]
def enable_mfa(email, type, secret, token, label, env):
if type == "totp":
validate_totp_secret(secret)
# Sanity check with the provide current token.
totp = pyotp.TOTP(secret)
if not totp.verify(token, valid_window=1):
raise ValueError("Invalid token.")
else:
raise ValueError("Invalid MFA type.")
conn, c = open_database(env, with_connection=True)
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, mfa_id, token, env):
conn, c = open_database(env, with_connection=True)
c.execute('UPDATE mfa SET mru_token=? WHERE user_id=? AND id=?', (token, get_user_id(email, c), mfa_id))
conn.commit()
def disable_mfa(email, mfa_id, env):
conn, c = open_database(env, with_connection=True)
if mfa_id is None:
# Disable all MFA for a user.
c.execute('DELETE FROM mfa WHERE user_id=?', (get_user_id(email, c),))
else:
# Disable a particular MFA mode for a user.
c.execute('DELETE FROM mfa WHERE user_id=? AND id=?', (get_user_id(email, c), mfa_id))
conn.commit()
return c.rowcount > 0
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")
def provision_totp(email, env):
# Make a new secret.
secret = base64.b32encode(os.urandom(20)).decode('utf-8')
validate_totp_secret(secret) # sanity check
# Make a URI that we encode within a QR code.
uri = pyotp.TOTP(secret).provisioning_uri(
name=email,
issuer_name=env["PRIMARY_HOSTNAME"] + " Mail-in-a-Box Control Panel"
)
# Generate a QR code as a base64-encode PNG image.
qr = qrcode.make(uri)
byte_arr = io.BytesIO()
qr.save(byte_arr, format='PNG')
png_b64 = base64.b64encode(byte_arr.getvalue()).decode('utf-8')
return {
"type": "totp",
"secret": secret,
"qr_code_base64": png_b64
}
def validate_auth_mfa(email, request, env):
# Validates that a login request satisfies any MFA modes
# that have been enabled for the user's account. Returns
# a tuple (status, [hints]). status is True for a successful
# MFA login, False for a missing token. If status is False,
# hints is an array of codes that indicate what the user
# can try. Possible codes are:
# "missing-totp-token"
# "invalid-totp-token"
mfa_state = get_mfa_state(email, env)
# If no MFA modes are added, return True.
if len(mfa_state) == 0:
return (True, [])
# Try the enabled MFA modes.
hints = set()
for mfa_mode in mfa_state:
if mfa_mode["type"] == "totp":
# Check that a token is present in the X-Auth-Token header.
# If not, give a hint that one can be supplied.
token = request.headers.get('x-auth-token')
if not token:
hints.add("missing-totp-token")
continue
# Check for a replay attack.
if hmac.compare_digest(token, mfa_mode['mru_token'] or ""):
# If the token fails, skip this MFA mode.
hints.add("invalid-totp-token")
continue
# Check the token.
totp = pyotp.TOTP(mfa_mode["secret"])
if not totp.verify(token, valid_window=1):
hints.add("invalid-totp-token")
continue
# On success, record the token to prevent a replay attack.
set_mru_token(email, mfa_mode['id'], token, env)
return (True, [])
# On a failed login, indicate failure and any hints for what the user can do instead.
return (False, list(hints))

View File

@ -651,10 +651,10 @@ def check_certificate(domain, ssl_certificate, ssl_private_key, warn_if_expiring
ndays = (cert_expiration_date-now).days ndays = (cert_expiration_date-now).days
if not rounded_time or ndays <= 10: if not rounded_time or ndays <= 10:
# Yikes better renew soon! # Yikes better renew soon!
expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.strftime("%x")) expiry_info = "The certificate expires in %d days on %s." % (ndays, cert_expiration_date.date().isoformat())
else: else:
# We'll renew it with Lets Encrypt. # We'll renew it with Lets Encrypt.
expiry_info = "The certificate expires on %s." % cert_expiration_date.strftime("%x") expiry_info = "The certificate expires on %s." % cert_expiration_date.date().isoformat()
if warn_if_expiring_soon and ndays <= warn_if_expiring_soon: if warn_if_expiring_soon and ndays <= warn_if_expiring_soon:
# Warn on day 10 to give 4 days for us to automatically renew the # Warn on day 10 to give 4 days for us to automatically renew the

View File

@ -97,11 +97,14 @@
</ul> </ul>
</li> </li>
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail <b class="caret"></b></a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Mail &amp; Users <b class="caret"></b></a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li> <li><a href="#mail-guide" onclick="return show_panel(this);">Instructions</a></li>
<li><a href="#users" onclick="return show_panel(this);">Users</a></li> <li><a href="#users" onclick="return show_panel(this);">Users</a></li>
<li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li> <li><a href="#aliases" onclick="return show_panel(this);">Aliases</a></li>
<li class="divider"></li>
<li class="dropdown-header">Your Account</li>
<li><a href="#mfa" onclick="return show_panel(this);">Two-Factor Authentication</a></li>
</ul> </ul>
</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>
@ -131,6 +134,10 @@
{% include "custom-dns.html" %} {% include "custom-dns.html" %}
</div> </div>
<div id="panel_mfa" class="admin_panel">
{% include "mfa.html" %}
</div>
<div id="panel_login" class="admin_panel"> <div id="panel_login" class="admin_panel">
{% include "login.html" %} {% include "login.html" %}
</div> </div>
@ -293,7 +300,7 @@ function ajax_with_indicator(options) {
} }
var api_credentials = ["", ""]; 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 // from http://www.webtoolkit.info/javascript-base64.html
function base64encode(input) { function base64encode(input) {
_keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; _keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
@ -331,7 +338,7 @@ function api(url, method, data, callback, callback_error) {
method: method, method: method,
cache: false, cache: false,
data: data, data: data,
headers: headers,
// the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding // the custom DNS api sends raw POST/PUT bodies --- prevent URL-encoding
processData: typeof data != "string", processData: typeof data != "string",
mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null, mimeType: typeof data == "string" ? "text/plain; charset=ascii" : null,
@ -359,6 +366,16 @@ function api(url, method, data, callback, callback_error) {
var current_panel = null; var current_panel = null;
var switch_back_to_panel = null; var switch_back_to_panel = null;
function do_logout() {
api_credentials = ["", ""];
if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials");
show_panel('login');
}
function show_panel(panelid) { function show_panel(panelid) {
if (panelid.getAttribute) if (panelid.getAttribute)
// we might be passed an HTMLElement <a>. // we might be passed an HTMLElement <a>.

View File

@ -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 %} {% if no_users_exist or no_admins_exist %}
<div class="row"> <div class="row">
@ -7,23 +32,23 @@
<p class="text-danger">There are no users on this system! To make an administrative user, <p class="text-danger">There are no users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p> log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox <pre>cd mailinabox
sudo tools/mail.py user add me@{{hostname}} sudo management/cli.py user add me@{{hostname}}
sudo tools/mail.py user make-admin me@{{hostname}}</pre> sudo management/cli.py user make-admin me@{{hostname}}</pre>
{% else %} {% else %}
<p class="text-danger">There are no administrative users on this system! To make an administrative user, <p class="text-danger">There are no administrative users on this system! To make an administrative user,
log into this machine using SSH (like when you first set it up) and run:</p> log into this machine using SSH (like when you first set it up) and run:</p>
<pre>cd mailinabox <pre>cd mailinabox
sudo tools/mail.py user make-admin me@{{hostname}}</pre> sudo management/cli.py user make-admin me@{{hostname}}</pre>
{% endif %} {% endif %}
<hr> <hr>
</div> </div>
</div> </div>
{% endif %} {% 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;"> <div class="login">
<form class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get"> <form id="loginForm" class="form-horizontal" role="form" onsubmit="do_login(); return false;" method="get">
<div class="form-group"> <div class="form-group">
<label for="inputEmail3" class="col-sm-3 control-label">Email</label> <label for="inputEmail3" class="col-sm-3 control-label">Email</label>
<div class="col-sm-9"> <div class="col-sm-9">
@ -36,6 +61,13 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
<input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password"> <input name="password" type="password" class="form-control" id="loginPassword" placeholder="Password">
</div> </div>
</div> </div>
<div class="form-group" id="loginOtp">
<label for="loginOtpInput" class="col-sm-3 control-label">Code</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="loginOtpInput" placeholder="6-digit code">
<div class="help-block" style="margin-top: 5px; font-size: 90%">Enter the six-digit code generated by your two factor authentication app.</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-3 col-sm-9"> <div class="col-sm-offset-3 col-sm-9">
<div class="checkbox"> <div class="checkbox">
@ -53,15 +85,15 @@ sudo tools/mail.py user make-admin me@{{hostname}}</pre>
</form> </form>
</div> </div>
<script> <script>
function do_login() { function do_login() {
if ($('#loginEmail').val() == "") { if ($('#loginEmail').val() == "") {
show_modal_error("Login Failed", "Enter your email address.", function() { show_modal_error("Login Failed", "Enter your email address.", function() {
$('#loginEmail').focus(); $('#loginEmail').focus();
}); });
return false; return false;
} }
if ($('#loginPassword').val() == "") { if ($('#loginPassword').val() == "") {
show_modal_error("Login Failed", "Enter your email password.", function() { show_modal_error("Login Failed", "Enter your email password.", function() {
$('#loginPassword').focus(); $('#loginPassword').focus();
@ -75,17 +107,29 @@ function do_login() {
api( api(
"/me", "/me",
"GET", "GET",
{ }, {},
function(response){ function(response) {
// This API call always succeeds. It returns a JSON object indicating // This API call always succeeds. It returns a JSON object indicating
// whether the request was authenticated or not. // whether the request was authenticated or not.
if (response.status != "ok") { if (response.status != 'ok') {
// Show why the login failed. if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
show_modal_error("Login Failed", response.reason) $('#loginForm').addClass('is-twofactor');
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');
// Reset any saved credentials. // Show why the login failed.
do_logout(); show_modal_error("Login Failed", response.reason)
// Reset any saved credentials.
do_logout();
}
} else if (!("api_key" in response)) { } else if (!("api_key" in response)) {
// Login succeeded but user might not be authorized! // Login succeeded but user might not be authorized!
show_modal_error("Login Failed", "You are not an administrator on this system.") show_modal_error("Login Failed", "You are not an administrator on this system.")
@ -102,6 +146,8 @@ function do_login() {
// Try to wipe the username/password information. // Try to wipe the username/password information.
$('#loginEmail').val(''); $('#loginEmail').val('');
$('#loginPassword').val(''); $('#loginPassword').val('');
$('#loginOtpInput').val('');
$('#loginForm').removeClass('is-twofactor');
// Remember the credentials. // Remember the credentials.
if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') { if (typeof localStorage != 'undefined' && typeof sessionStorage != 'undefined') {
@ -119,19 +165,16 @@ function do_login() {
// which confuses the loading indicator. // 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); setTimeout(function() { show_panel(!switch_back_to_panel || switch_back_to_panel == "login" ? 'system_status' : switch_back_to_panel) }, 300);
} }
}) },
} undefined,
{
function do_logout() { 'x-auth-token': $('#loginOtpInput').val()
api_credentials = ["", ""]; });
if (typeof localStorage != 'undefined')
localStorage.removeItem("miab-cp-credentials");
if (typeof sessionStorage != 'undefined')
sessionStorage.removeItem("miab-cp-credentials");
show_panel('login');
} }
function show_login() { function show_login() {
$('#loginForm').removeClass('is-twofactor');
$('#loginOtpInput').val('');
$('#loginEmail,#loginPassword').each(function() { $('#loginEmail,#loginPassword').each(function() {
var input = $(this); var input = $(this);
if (!$.trim(input.val())) { if (!$.trim(input.val())) {

View File

@ -0,0 +1,242 @@
<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>
<p>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.</p>
<div class="panel panel-danger">
<div class="panel-heading">
Enabling two-factor authentication does not protect access to your email
</div>
<div class="panel-body">
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. <strong>Always use a strong password,
and ensure every administrator account for this control panel does the same.</strong>
</div>
</div>
<div class="twofactor">
<div class="loading-indicator">Loading...</div>
<form id="totp-setup">
<h3>Setup Instructions</h3>
<div class="form-group">
<p>1. Install <a href="https://freeotp.github.io/">FreeOTP</a> or <a href="https://www.pcworld.com/article/3225913/what-is-two-factor-authentication-and-which-2fa-apps-are-best.html">any
other two-factor authentication app</a> that supports TOTP.</p>
</div>
<div class="form-group">
<p style="margin-bottom: 0">2. Scan the QR code in the app or directly enter the secret into the app:</p>
<div id="totp-setup-qr"></div>
</div>
<div class="form-group">
<label for="otp-label" style="font-weight: normal">3. Optionally, give your device a label so that you can remember what device you set it up on:</label>
<input type="text" id="totp-setup-label" class="form-control" placeholder="my phone" />
</div>
<div class="form-group">
<label for="otp" style="font-weight: normal">4. Use the app to generate your first six-digit code and enter it here:</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">
<p>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.</p>
<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 for your account<span id="mfa-device-label"></span>.</p>
<p>You will have to log into the admin panel again after disabling two-factor authentication.</p>
</div>
<div class="form-group">
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</div>
</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'),
totpSetupLabel: document.getElementById('totp-setup-label'),
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(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: ${provisioned_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', provisioned_totp.secret);
el.wrapper.classList.add('disabled');
}
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() {
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_mfa() {
reset_view();
api(
'/mfa/status',
'POST',
{},
function(res) {
el.wrapper.classList.add('loaded');
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);
}
);
}
function do_disable(evt) {
evt.preventDefault();
hide_error();
api(
'/mfa/disable',
'POST',
{ type: 'totp' },
function() {
do_logout();
}
);
return false;
}
function do_enable_totp(evt) {
evt.preventDefault();
hide_error();
api(
'/mfa/totp/enable',
'POST',
{
token: $(el.totpSetupToken).val(),
secret: $(el.totpSetupSecret).val(),
label: $(el.totpSetupLabel).val()
},
function(res) { do_logout(); },
function(res) { render_error(res); }
);
return false;
}
</script>

View File

@ -113,10 +113,10 @@
</div> </div>
<!-- Common --> <!-- Common -->
<div class="form-group backup-target-local backup-target-rsync backup-target-s3"> <div class="form-group backup-target-local backup-target-rsync backup-target-s3">
<label for="min-age" class="col-sm-2 control-label">Days:</label> <label for="min-age" class="col-sm-2 control-label">Retention Days:</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="number" class="form-control" rows="1" id="min-age"> <input type="number" class="form-control" rows="1" id="min-age">
<div class="small" style="margin-top: 2px">This is the <i>minimum</i> number of days backup data is kept for. The box makes an incremental backup, so backup data is often kept much longer. An incremental backup file that is less than this number of days old requires that all previous increments back to the most recent full backup, plus that full backup, remain available.</div> <div class="small" style="margin-top: 2px">This is the minimum time backup data is kept for. The box makes an incremental backup most nights, which requires that previous backups back to the most recent full backup be preserved, so backup data is often kept much longer than this setting. Full backups are made periodically when the incremental backup data size exceeds a limit.</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -1,6 +1,6 @@
# If there aren't any mail users yet, create one. # If there aren't any mail users yet, create one.
if [ -z "`tools/mail.py user`" ]; then if [ -z "`management/cli.py user`" ]; then
# The outut of "tools/mail.py user" is a list of mail users. If there # The outut of "management/cli.py user" is a list of mail users. If there
# aren't any yet, it'll be empty. # aren't any yet, it'll be empty.
# If we didn't ask for an email address at the start, do so now. # If we didn't ask for an email address at the start, do so now.
@ -47,11 +47,11 @@ if [ -z "`tools/mail.py user`" ]; then
fi fi
# Create the user's mail account. This will ask for a password if none was given above. # Create the user's mail account. This will ask for a password if none was given above.
tools/mail.py user add $EMAIL_ADDR ${EMAIL_PW:-} management/cli.py user add $EMAIL_ADDR ${EMAIL_PW:-}
# Make it an admin. # Make it an admin.
hide_output tools/mail.py user make-admin $EMAIL_ADDR hide_output management/cli.py user make-admin $EMAIL_ADDR
# Create an alias to which we'll direct all automatically-created administrative aliases. # Create an alias to which we'll direct all automatically-created administrative aliases.
tools/mail.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null management/cli.py alias add administrator@$PRIMARY_HOSTNAME $EMAIL_ADDR > /dev/null
fi fi

View File

@ -22,6 +22,7 @@ if [ ! -f $db_path ]; then
echo Creating new user database: $db_path; 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 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 aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);" | sqlite3 $db_path;
echo "CREATE TABLE mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, 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 fi
# ### User Authentication # ### User Authentication

View File

@ -50,6 +50,7 @@ hide_output $venv/bin/pip install --upgrade pip
hide_output $venv/bin/pip install --upgrade \ hide_output $venv/bin/pip install --upgrade \
rtyaml "email_validator>=1.0.0" "exclusiveprocess" \ rtyaml "email_validator>=1.0.0" "exclusiveprocess" \
flask dnspython python-dateutil \ flask dnspython python-dateutil \
qrcode[pil] pyotp \
"idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver "idna>=2.0.0" "cryptography==2.2.2" boto psutil postfix-mta-sts-resolver
# CONFIGURATION # CONFIGURATION

View File

@ -181,6 +181,12 @@ def migration_12(env):
conn.commit() conn.commit()
conn.close() conn.close()
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 mfa (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, secret TEXT NOT NULL, mru_token TEXT, label TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);"])
###########################################################
def get_current_migration(): def get_current_migration():
ver = 0 ver = 0

View File

@ -14,12 +14,18 @@ apt-get purge -qq -y owncloud* # we used to use the package manager
apt_install php php-fpm \ apt_install php php-fpm \
php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \ php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl \
php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \ php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json \
php-intl php-imagick php-intl php-imagick php-gmp php-bcmath
InstallNextcloud() { InstallNextcloud() {
version=$1 version=$1
hash=$2 hash=$2
version_contacts=$3
hash_contacts=$4
version_calendar=$5
hash_calendar=$6
version_user_external=$7
hash_user_external=$8
echo echo
echo "Upgrading to Nextcloud version $version" echo "Upgrading to Nextcloud version $version"
@ -40,18 +46,18 @@ InstallNextcloud() {
# their github repositories. # their github repositories.
mkdir -p /usr/local/lib/owncloud/apps mkdir -p /usr/local/lib/owncloud/apps
wget_verify https://github.com/nextcloud/contacts/releases/download/v3.3.0/contacts.tar.gz e55d0357c6785d3b1f3b5f21780cb6d41d32443a /tmp/contacts.tgz wget_verify https://github.com/nextcloud/contacts/releases/download/v$version_contacts/contacts.tar.gz $hash_contacts /tmp/contacts.tgz
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/ tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/contacts.tgz rm /tmp/contacts.tgz
wget_verify https://github.com/nextcloud/calendar/releases/download/v2.0.3/calendar.tar.gz 9d9717b29337613b72c74e9914c69b74b346c466 /tmp/calendar.tgz wget_verify https://github.com/nextcloud/calendar/releases/download/v$version_calendar/calendar.tar.gz $hash_calendar /tmp/calendar.tgz
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/ tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/calendar.tgz rm /tmp/calendar.tgz
# Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core, # Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core,
# we will install from their github repository. # we will install from their github repository.
if [[ $version =~ ^1[567] ]]; then if [ -n "$version_user_external" ]; then
wget_verify https://github.com/nextcloud/user_external/releases/download/v0.7.0/user_external-0.7.0.tar.gz 555a94811daaf5bdd336c5e48a78aa8567b86437 /tmp/user_external.tgz wget_verify https://github.com/nextcloud/user_external/releases/download/v$version_user_external/user_external-$version_user_external.tar.gz $hash_user_external /tmp/user_external.tgz
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/ tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/user_external.tgz rm /tmp/user_external.tgz
fi fi
@ -91,8 +97,14 @@ InstallNextcloud() {
} }
# Nextcloud Version to install. Checks are done down below to step through intermediate versions. # Nextcloud Version to install. Checks are done down below to step through intermediate versions.
nextcloud_ver=17.0.6 nextcloud_ver=20.0.1
nextcloud_hash=50b98d2c2f18510b9530e558ced9ab51eb4f11b0 nextcloud_hash=f2b3faa570c541df73f209e873a1c2852e79eab8
contacts_ver=3.4.1
contacts_hash=aee680a75e95f26d9285efd3c1e25cf7f3bfd27e
calendar_ver=2.1.2
calendar_hash=930c07863bb7a65652dec34793802c8d80502336
user_external_ver=1.0.0
user_external_hash=3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
# Current Nextcloud Version, #1623 # Current Nextcloud Version, #1623
# Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB # Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB
@ -141,23 +153,39 @@ if [ ! -d /usr/local/lib/owncloud/ ] || [[ ! ${CURRENT_NEXTCLOUD_VER} =~ ^$nextc
return 0 return 0
elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then elif [[ ${CURRENT_NEXTCLOUD_VER} =~ ^13 ]]; then
# If we are running Nextcloud 13, upgrade to Nextcloud 14 # If we are running Nextcloud 13, upgrade to Nextcloud 14
InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466
CURRENT_NEXTCLOUD_VER="14.0.6" CURRENT_NEXTCLOUD_VER="14.0.6"
fi fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^14 ]]; then
# During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail. # During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail.
# We will disable it here before the upgrade and install it again after the upgrade. # We will disable it here before the upgrade and install it again after the upgrade.
hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external hide_output sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 InstallNextcloud 15.0.8 4129d8d4021c435f2e86876225fb7f15adf764a3 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="15.0.8" CURRENT_NEXTCLOUD_VER="15.0.8"
fi fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^15 ]]; then
InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 InstallNextcloud 16.0.6 0bb3098455ec89f5af77a652aad553ad40a88819 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="16.0.6" CURRENT_NEXTCLOUD_VER="16.0.6"
fi fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^16 ]]; then
InstallNextcloud 17.0.6 50b98d2c2f18510b9530e558ced9ab51eb4f11b0 3.3.0 e55d0357c6785d3b1f3b5f21780cb6d41d32443a 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 0.7.0 555a94811daaf5bdd336c5e48a78aa8567b86437
CURRENT_NEXTCLOUD_VER="17.0.6"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^17 ]]; then
echo "ALTER TABLE oc_flow_operations ADD COLUMN entity VARCHAR;" | sqlite3 $STORAGE_ROOT/owncloud/owncloud.db
InstallNextcloud 18.0.10 39c0021a8b8477c3f1733fddefacfa5ebf921c68 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
CURRENT_NEXTCLOUD_VER="18.0.10"
fi
if [[ ${CURRENT_NEXTCLOUD_VER} =~ ^18 ]]; then
InstallNextcloud 19.0.4 01e98791ba12f4860d3d4047b9803f97a1b55c60 3.4.1 aee680a75e95f26d9285efd3c1e25cf7f3bfd27e 2.0.3 9d9717b29337613b72c74e9914c69b74b346c466 1.0.0 3bf2609061d7214e7f0f69dd8883e55c4ec8f50a
CURRENT_NEXTCLOUD_VER="19.0.4"
fi
fi fi
InstallNextcloud $nextcloud_ver $nextcloud_hash InstallNextcloud $nextcloud_ver $nextcloud_hash $contacts_ver $contacts_hash $calendar_ver $calendar_hash $user_external_ver $user_external_hash
# Nextcloud 20 needs to have some optional columns added
sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-columns
fi fi
# ### Configuring Nextcloud # ### Configuring Nextcloud
@ -324,7 +352,7 @@ rm -f /etc/cron.hourly/mailinabox-owncloud
# and there's a lot they could mess up, so we don't make any users admins of Nextcloud. # and there's a lot they could mess up, so we don't make any users admins of Nextcloud.
# But if we wanted to, we would do this: # But if we wanted to, we would do this:
# ``` # ```
# for user in $(tools/mail.py user admins); do # for user in $(management/cli.py user admins); do
# sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')" # sqlite3 $STORAGE_ROOT/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')"
# done # done
# ``` # ```

View File

@ -28,8 +28,8 @@ apt_install \
# Install Roundcube from source if it is not already present or if it is out of date. # Install Roundcube from source if it is not already present or if it is out of date.
# Combine the Roundcube version number with the commit hash of plugins to track # Combine the Roundcube version number with the commit hash of plugins to track
# whether we have the latest version of everything. # whether we have the latest version of everything.
VERSION=1.4.8 VERSION=1.4.9
HASH=3a6824fd68fef2e0d24f186cfbee5c6f9d6edbe9 HASH=df650f4d3eae9eaae2d5a5f06d68665691daf57d
PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435 PERSISTENT_LOGIN_VERSION=6b3fc450cae23ccb2f393d0ef67aa319e877e435
HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5 HTML5_NOTIFIER_VERSION=4b370e3cd60dabd2f428a26f45b677ad1b7118d5
CARDDAV_VERSION=3.0.3 CARDDAV_VERSION=3.0.3

View File

@ -1,128 +1,3 @@
#!/usr/bin/python3 #!/bin/bash
# This script has moved.
import sys, getpass, urllib.request, urllib.error, json, re management/cli.py "$@"
def mgmt(cmd, data=None, is_json=False):
# The base URL for the management daemon. (Listens on IPv4 only.)
mgmt_uri = 'http://127.0.0.1:10222'
setup_key_auth(mgmt_uri)
req = urllib.request.Request(mgmt_uri + cmd, urllib.parse.urlencode(data).encode("utf8") if data else None)
try:
response = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
if e.code == 401:
try:
print(e.read().decode("utf8"))
except:
pass
print("The management daemon refused access. The API key file may be out of sync. Try 'service mailinabox restart'.", file=sys.stderr)
elif hasattr(e, 'read'):
print(e.read().decode('utf8'), file=sys.stderr)
else:
print(e, file=sys.stderr)
sys.exit(1)
resp = response.read().decode('utf8')
if is_json: resp = json.loads(resp)
return resp
def read_password():
while True:
first = getpass.getpass('password: ')
if len(first) < 8:
print("Passwords must be at least eight characters.")
continue
second = getpass.getpass(' (again): ')
if first != second:
print("Passwords not the same. Try again.")
continue
break
return first
def setup_key_auth(mgmt_uri):
key = open('/var/lib/mailinabox/api.key').read().strip()
auth_handler = urllib.request.HTTPBasicAuthHandler()
auth_handler.add_password(
realm='Mail-in-a-Box Management Server',
uri=mgmt_uri,
user=key,
passwd='')
opener = urllib.request.build_opener(auth_handler)
urllib.request.install_opener(opener)
if len(sys.argv) < 2:
print("Usage: ")
print(" tools/mail.py user (lists users)")
print(" tools/mail.py user add user@domain.com [password]")
print(" tools/mail.py user password user@domain.com [password]")
print(" tools/mail.py user remove user@domain.com")
print(" tools/mail.py user make-admin user@domain.com")
print(" tools/mail.py user remove-admin user@domain.com")
print(" tools/mail.py user admins (lists admins)")
print(" tools/mail.py alias (lists aliases)")
print(" tools/mail.py alias add incoming.name@domain.com sent.to@other.domain.com")
print(" tools/mail.py alias add incoming.name@domain.com 'sent.to@other.domain.com, multiple.people@other.domain.com'")
print(" tools/mail.py alias remove incoming.name@domain.com")
print()
print("Removing a mail user does not delete their mail folders on disk. It only prevents IMAP/SMTP login.")
print()
elif sys.argv[1] == "user" and len(sys.argv) == 2:
# Dump a list of users, one per line. Mark admins with an asterisk.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if user['status'] == 'inactive': continue
print(user['email'], end='')
if "admin" in user['privileges']:
print("*", end='')
print()
elif sys.argv[1] == "user" and sys.argv[2] in ("add", "password"):
if len(sys.argv) < 5:
if len(sys.argv) < 4:
email = input("email: ")
else:
email = sys.argv[3]
pw = read_password()
else:
email, pw = sys.argv[3:5]
if sys.argv[2] == "add":
print(mgmt("/mail/users/add", { "email": email, "password": pw }))
elif sys.argv[2] == "password":
print(mgmt("/mail/users/password", { "email": email, "password": pw }))
elif sys.argv[1] == "user" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/users/remove", { "email": sys.argv[3] }))
elif sys.argv[1] == "user" and sys.argv[2] in ("make-admin", "remove-admin") and len(sys.argv) == 4:
if sys.argv[2] == "make-admin":
action = "add"
else:
action = "remove"
print(mgmt("/mail/users/privileges/" + action, { "email": sys.argv[3], "privilege": "admin" }))
elif sys.argv[1] == "user" and sys.argv[2] == "admins":
# Dump a list of admin users.
users = mgmt("/mail/users?format=json", is_json=True)
for domain in users:
for user in domain["users"]:
if "admin" in user['privileges']:
print(user['email'])
elif sys.argv[1] == "alias" and len(sys.argv) == 2:
print(mgmt("/mail/aliases"))
elif sys.argv[1] == "alias" and sys.argv[2] == "add" and len(sys.argv) == 5:
print(mgmt("/mail/aliases/add", { "address": sys.argv[3], "forwards_to": sys.argv[4] }))
elif sys.argv[1] == "alias" and sys.argv[2] == "remove" and len(sys.argv) == 4:
print(mgmt("/mail/aliases/remove", { "address": sys.argv[3] }))
else:
print("Invalid command-line arguments.")
sys.exit(1)