mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2024-11-25 02:47:04 +00:00
split management daemon authorization from authentication and use 'doveadm pw' rather than 'doveadm auth test' so that it is decoupled from dovecot's login mechanism
This was done to pave the way for two-factor authentication, but that's still a ways off.
This commit is contained in:
parent
3187053b3a
commit
023b38df50
@ -3,7 +3,7 @@ import base64, os, os.path
|
|||||||
from flask import make_response
|
from flask import make_response
|
||||||
|
|
||||||
import utils
|
import utils
|
||||||
from mailconfig import get_mail_user_privileges
|
from mailconfig import get_mail_password, get_mail_user_privileges
|
||||||
|
|
||||||
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'
|
||||||
@ -40,10 +40,11 @@ class KeyAuthService:
|
|||||||
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
with create_file_with_mode(self.key_path, 0o640) as key_file:
|
||||||
key_file.write(self.key + '\n')
|
key_file.write(self.key + '\n')
|
||||||
|
|
||||||
def is_authenticated(self, request, env):
|
def authenticate(self, request, env):
|
||||||
"""Test if the client key passed in HTTP Authorization header matches the service key
|
"""Test if the client key passed in HTTP Authorization header matches the service key
|
||||||
or if the or username/password passed in the header matches an administrator user.
|
or if the or username/password passed in the header matches an administrator user.
|
||||||
Returns 'OK' if the key is good or the user is an administrator, otherwise an error message."""
|
Returns a list of user privileges (e.g. [] or ['admin']) raise a ValueError on
|
||||||
|
login failure."""
|
||||||
|
|
||||||
def decode(s):
|
def decode(s):
|
||||||
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
return base64.b64decode(s.encode('ascii')).decode('ascii')
|
||||||
@ -63,46 +64,56 @@ class KeyAuthService:
|
|||||||
|
|
||||||
header = request.headers.get('Authorization')
|
header = request.headers.get('Authorization')
|
||||||
if not header:
|
if not header:
|
||||||
return "No authorization header provided."
|
raise ValueError("No authorization header provided.")
|
||||||
|
|
||||||
username, password = parse_basic_auth(header)
|
username, password = parse_basic_auth(header)
|
||||||
|
|
||||||
if username in (None, ""):
|
if username in (None, ""):
|
||||||
return "Authorization header invalid."
|
raise ValueError("Authorization header invalid.")
|
||||||
elif username == self.key:
|
elif username == self.key:
|
||||||
return "OK"
|
# The user passed the API key which grants administrative privs.
|
||||||
|
return ["admin"]
|
||||||
else:
|
else:
|
||||||
return self.check_imap_login( username, password, env)
|
# The user is trying to log in with a username and password.
|
||||||
|
# Raises or returns privs.
|
||||||
|
return self.get_user_credentials(username, password, env)
|
||||||
|
|
||||||
def check_imap_login(self, email, pw, env):
|
def get_user_credentials(self, email, pw, env):
|
||||||
# Validate a user's credentials.
|
# Validate a user's credentials. On success returns a list of
|
||||||
|
# privileges (e.g. [] or ['admin']). On failure raises a ValueError
|
||||||
|
# with a login error message.
|
||||||
|
|
||||||
# Sanity check.
|
# Sanity check.
|
||||||
if email == "" or pw == "":
|
if email == "" or pw == "":
|
||||||
return "Enter an email address and password."
|
raise ValueError("Enter an email address and password.")
|
||||||
|
|
||||||
|
# Get the hashed password of the user. Raise a ValueError if the
|
||||||
|
# email address does not correspond to a user.
|
||||||
|
pw_hash = get_mail_password(email, env)
|
||||||
|
|
||||||
# Authenticate.
|
# Authenticate.
|
||||||
try:
|
try:
|
||||||
# Use doveadm to check credentials. doveadm will return
|
# Use 'doveadm pw' to check credentials. doveadm will return
|
||||||
# a non-zero exit status if the credentials are no good,
|
# a non-zero exit status if the credentials are no good,
|
||||||
# and check_call will raise an exception in that case.
|
# and check_call will raise an exception in that case.
|
||||||
utils.shell('check_call', [
|
utils.shell('check_call', [
|
||||||
"/usr/bin/doveadm",
|
"/usr/bin/doveadm", "pw",
|
||||||
"auth", "test",
|
"-p", pw,
|
||||||
email, pw
|
"-t", pw_hash,
|
||||||
])
|
])
|
||||||
except:
|
except:
|
||||||
# Login failed.
|
# Login failed.
|
||||||
return "Invalid email address or password."
|
raise ValueError("Invalid password.")
|
||||||
|
|
||||||
# Authorize.
|
# Get privileges for authorization.
|
||||||
# (This call should never fail on a valid user.)
|
|
||||||
|
# (This call should never fail on a valid user. But if it did fail, it would
|
||||||
|
# return a tuple of an error message and an HTTP status code.)
|
||||||
privs = get_mail_user_privileges(email, env)
|
privs = get_mail_user_privileges(email, env)
|
||||||
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
if isinstance(privs, tuple): raise Exception("Error getting privileges.")
|
||||||
if "admin" not in privs:
|
|
||||||
return "You are not an administrator for this system."
|
|
||||||
|
|
||||||
return "OK"
|
# Return a list of privileges.
|
||||||
|
return privs
|
||||||
|
|
||||||
def _generate_key(self):
|
def _generate_key(self):
|
||||||
raw_key = os.urandom(32)
|
raw_key = os.urandom(32)
|
||||||
|
@ -24,19 +24,32 @@ except OSError:
|
|||||||
|
|
||||||
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
app = Flask(__name__, template_folder=os.path.abspath(os.path.join(os.path.dirname(me), "templates")))
|
||||||
|
|
||||||
# Decorator to protect views that require authentication.
|
# Decorator to protect views that require a user with 'admin' privileges.
|
||||||
def authorized_personnel_only(viewfunc):
|
def authorized_personnel_only(viewfunc):
|
||||||
@wraps(viewfunc)
|
@wraps(viewfunc)
|
||||||
def newview(*args, **kwargs):
|
def newview(*args, **kwargs):
|
||||||
# Check if the user is authorized.
|
# Authenticate the passed credentials, which is either the API key or a username:password pair.
|
||||||
authorized_status = auth_service.is_authenticated(request, env)
|
error = None
|
||||||
if authorized_status == "OK":
|
try:
|
||||||
# Authorized. Call view func.
|
privs = auth_service.authenticate(request, env)
|
||||||
|
except ValueError as e:
|
||||||
|
# Authentication failed.
|
||||||
|
privs = []
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
# Authorized to access an API view?
|
||||||
|
if "admin" in privs:
|
||||||
|
# Call view func.
|
||||||
return viewfunc(*args, **kwargs)
|
return viewfunc(*args, **kwargs)
|
||||||
|
elif not error:
|
||||||
|
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.
|
||||||
status = 401
|
status = 401
|
||||||
headers = { 'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm) }
|
headers = {
|
||||||
|
'WWW-Authenticate': 'Basic realm="{0}"'.format(auth_service.auth_realm),
|
||||||
|
'X-Reason': error,
|
||||||
|
}
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
# Don't issue a 401 to an AJAX request because the user will
|
# Don't issue a 401 to an AJAX request because the user will
|
||||||
@ -46,13 +59,13 @@ def authorized_personnel_only(viewfunc):
|
|||||||
|
|
||||||
if request.headers.get('Accept') in (None, "", "*/*"):
|
if request.headers.get('Accept') in (None, "", "*/*"):
|
||||||
# Return plain text output.
|
# Return plain text output.
|
||||||
return Response(authorized_status+"\n", status=status, mimetype='text/plain', headers=headers)
|
return Response(error+"\n", status=status, mimetype='text/plain', headers=headers)
|
||||||
else:
|
else:
|
||||||
# Return JSON output.
|
# Return JSON output.
|
||||||
return Response(json.dumps({
|
return Response(json.dumps({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"reason": authorized_status
|
"reason": error,
|
||||||
}+"\n"), status=status, mimetype='application/json', headers=headers)
|
})+"\n", status=status, mimetype='application/json', headers=headers)
|
||||||
|
|
||||||
return newview
|
return newview
|
||||||
|
|
||||||
@ -81,17 +94,26 @@ def index():
|
|||||||
@app.route('/me')
|
@app.route('/me')
|
||||||
def me():
|
def me():
|
||||||
# Is the caller authorized?
|
# Is the caller authorized?
|
||||||
authorized_status = auth_service.is_authenticated(request, env)
|
try:
|
||||||
if authorized_status != "OK":
|
privs = auth_service.authenticate(request, env)
|
||||||
|
except ValueError as e:
|
||||||
return json_response({
|
return json_response({
|
||||||
"status": "not-authorized",
|
"status": "invalid",
|
||||||
"reason": authorized_status,
|
"reason": str(e),
|
||||||
})
|
|
||||||
return json_response({
|
|
||||||
"status": "authorized",
|
|
||||||
"api_key": auth_service.key,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"status": "ok",
|
||||||
|
"privileges": privs,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is authorized as admin?
|
||||||
|
if "admin" in privs:
|
||||||
|
resp["api_key"] = auth_service.key
|
||||||
|
|
||||||
|
# Return.
|
||||||
|
return json_response(resp)
|
||||||
|
|
||||||
# MAIL
|
# MAIL
|
||||||
|
|
||||||
@app.route('/mail/users')
|
@app.route('/mail/users')
|
||||||
|
@ -279,7 +279,7 @@ def add_mail_user(email, pw, privs, env):
|
|||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
|
|
||||||
# hash the password
|
# hash the password
|
||||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
pw = hash_password(pw)
|
||||||
|
|
||||||
# add the user to the database
|
# add the user to the database
|
||||||
try:
|
try:
|
||||||
@ -319,7 +319,7 @@ def set_mail_password(email, pw, env):
|
|||||||
validate_password(pw)
|
validate_password(pw)
|
||||||
|
|
||||||
# hash the password
|
# hash the password
|
||||||
pw = utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
pw = hash_password(pw)
|
||||||
|
|
||||||
# update the database
|
# update the database
|
||||||
conn, c = open_database(env, with_connection=True)
|
conn, c = open_database(env, with_connection=True)
|
||||||
@ -329,6 +329,24 @@ def set_mail_password(email, pw, env):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
def hash_password(pw):
|
||||||
|
# Turn the plain password into a Dovecot-format hashed password, meaning
|
||||||
|
# something like "{SCHEME}hashedpassworddata".
|
||||||
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
|
return utils.shell('check_output', ["/usr/bin/doveadm", "pw", "-s", "SHA512-CRYPT", "-p", pw]).strip()
|
||||||
|
|
||||||
|
def get_mail_password(email, env):
|
||||||
|
# Gets the hashed password for a user. Passwords are stored in Dovecot's
|
||||||
|
# password format, with a prefixed scheme.
|
||||||
|
# http://wiki2.dovecot.org/Authentication/PasswordSchemes
|
||||||
|
# update the database
|
||||||
|
c = open_database(env)
|
||||||
|
c.execute('SELECT password FROM users WHERE email=?', (email,))
|
||||||
|
rows = c.fetchall()
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise ValueError("That's not a user (%s)." % email)
|
||||||
|
return rows[0][0]
|
||||||
|
|
||||||
def remove_mail_user(email, env):
|
def remove_mail_user(email, env):
|
||||||
# accept IDNA domain names but normalize to Unicode before going into database
|
# accept IDNA domain names but normalize to Unicode before going into database
|
||||||
email = sanitize_idn_email_address(email)
|
email = sanitize_idn_email_address(email)
|
||||||
|
@ -67,13 +67,20 @@ function do_login() {
|
|||||||
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 != "authorized") {
|
if (response.status != "ok") {
|
||||||
// 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)) {
|
||||||
|
// Login succeeded but user might not be authorized!
|
||||||
|
show_modal_error("Login Failed", "You are not an administrator on this system.")
|
||||||
|
|
||||||
|
// Reset any saved credentials.
|
||||||
|
do_logout();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Login succeeded.
|
// Login succeeded.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user