Merge branch 'master' into master
This commit is contained in:
commit
31ff3b9d18
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -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)
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
if "missing-totp-token" in str(e):
|
||||||
|
return json_response({
|
||||||
|
"status": "missing-totp-token",
|
||||||
|
"reason": str(e),
|
||||||
|
})
|
||||||
|
else:
|
||||||
# Log the failed login
|
# Log the failed login
|
||||||
log_failed_login(request)
|
log_failed_login(request)
|
||||||
|
|
||||||
return json_response({
|
return json_response({
|
||||||
"status": "invalid",
|
"status": "invalid",
|
||||||
"reason": "Incorrect username or password",
|
"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
|
||||||
|
|
||||||
|
|
|
@ -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":
|
||||||
|
|
|
@ -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))
|
|
@ -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
|
||||||
|
|
|
@ -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 & 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>.
|
||||||
|
|
|
@ -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,7 +85,6 @@ 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() == "") {
|
||||||
|
@ -62,6 +93,7 @@ function do_login() {
|
||||||
});
|
});
|
||||||
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') {
|
||||||
|
if (response.status === 'missing-totp-token' || (response.status === 'invalid' && response.reason == 'invalid-totp-token')) {
|
||||||
|
$('#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');
|
||||||
|
|
||||||
// Show why the login failed.
|
// Show why the login failed.
|
||||||
show_modal_error("Login Failed", response.reason)
|
show_modal_error("Login Failed", response.reason)
|
||||||
|
|
||||||
// Reset any saved credentials.
|
// Reset any saved credentials.
|
||||||
do_logout();
|
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())) {
|
||||||
|
|
|
@ -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>
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
# ```
|
# ```
|
||||||
|
|
|
@ -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
|
||||||
|
|
131
tools/mail.py
131
tools/mail.py
|
@ -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)
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue