move passwords to file in user maildir

This commit is contained in:
holger krekel
2024-07-22 16:55:24 +02:00
parent 1b3e2b32f2
commit eddfadaf7f
7 changed files with 85 additions and 158 deletions

View File

@@ -1,3 +1,5 @@
import os
import sys
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -50,6 +52,21 @@ class Config:
res["password"] = enc_password res["password"] = enc_password
return res return res
def set_user_password(self, addr, enc_password):
# reading and writing user data needs to be atomic
# to allow concurrent logins to succeed.
userdir = self.get_user_maildir(addr)
try:
userdir.mkdir()
except FileExistsError:
pass
password_path = userdir.joinpath("password")
password_path_tmp = userdir.joinpath("password.tmp")
password_path_tmp.write_text(enc_password)
os.rename(password_path_tmp, password_path)
print(f"Created address: {addr}", file=sys.stderr)
return self.get_user_dict(addr=addr, enc_password=enc_password)
def write_initial_config(inipath, mail_domain, overrides): def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides.""" """Write out default config file, using the specified config value overrides."""

View File

@@ -7,35 +7,17 @@ import sys
import time import time
from .config import read_config from .config import read_config
from .database import Database
from .lastlogin import get_last_login_from_userdir from .lastlogin import get_last_login_from_userdir
def delete_inactive_users(db, config, chunksize=100): def delete_inactive_users(config):
cutoff_date = time.time() - config.delete_inactive_users_after * 86400 cutoff_date = time.time() - config.delete_inactive_users_after * 86400
pending = []
def remove(userdir):
shutil.rmtree(userdir, ignore_errors=True)
pending.append(userdir.name)
if len(pending) > chunksize:
clear_pending()
def clear_pending():
with db.write_transaction() as conn:
for user in pending:
conn.execute("DELETE FROM users WHERE addr = ?", (user,))
pending.clear()
for userdir in config.mailboxes_dir.iterdir(): for userdir in config.mailboxes_dir.iterdir():
if get_last_login_from_userdir(userdir) < cutoff_date: if get_last_login_from_userdir(userdir) < cutoff_date:
remove(userdir) shutil.rmtree(userdir, ignore_errors=True)
clear_pending()
def main(): def main():
(cfgpath,) = sys.argv[1:] (cfgpath,) = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
db = Database(config.passdb_path) delete_inactive_users(config)
delete_inactive_users(db, config)

View File

@@ -3,19 +3,13 @@ import json
import logging import logging
import os import os
import sys import sys
from pathlib import Path
from .config import Config, read_config from .config import Config, read_config
from .database import Database
from .dictproxy import DictProxy from .dictproxy import DictProxy
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
class UnknownCommand(ValueError):
"""dictproxy handler received an unkown command"""
def encrypt_password(password: str): def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ # https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512) passhash = crypt.crypt(password, crypt.METHOD_SHA512)
@@ -60,61 +54,26 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def get_user_data(db, config: Config, user, conn=None): def lookup_userdb(config: Config, user):
if user == f"echo@{config.mail_domain}": userdir = config.get_user_maildir(user)
return config.get_user_dict(user) password_path = userdir.joinpath("password")
result = {}
if conn is None: if password_path.exists():
with db.read_connection() as conn: result = dict(addr=user, password=password_path.read_text())
result = conn.get_user(user)
else:
result = conn.get_user(user)
if result:
result.update(config.get_user_dict(user)) result.update(config.get_user_dict(user))
return result return result
def lookup_userdb(db, config: Config, user): def lookup_passdb(config: Config, user, cleartext_password):
return get_user_data(db, config, user) userdata = lookup_userdb(config, user)
def lookup_passdb(db, config: Config, user, cleartext_password):
if user == f"echo@{config.mail_domain}":
# Echobot writes password it wants to log in with into /run/echobot/password
try:
password = Path("/run/echobot/password").read_text()
except Exception:
logging.exception("Exception when trying to read /run/echobot/password")
return None
return config.get_user_dict(user, enc_password=encrypt_password(password))
userdata = get_user_data(db, config, user)
if userdata: if userdata:
return userdata return userdata
if not is_allowed_to_create(config, user, cleartext_password): if not is_allowed_to_create(config, user, cleartext_password):
return return
# reading and writing user data needs to be atomic enc_password = encrypt_password(cleartext_password)
# to allow concurrent logins to succeed. config.set_user_password(user, enc_password=enc_password)
with db.write_transaction() as conn: return config.get_user_dict(user, enc_password=enc_password)
userdata = get_user_data(db, config, user, conn=conn)
if userdata:
return userdata
enc_password = encrypt_password(cleartext_password)
q = "INSERT INTO users (addr, password) VALUES (?, ?)"
conn.execute(q, (user, enc_password))
print(f"Created address: {user}", file=sys.stderr)
return config.get_user_dict(user, enc_password=enc_password)
def iter_userdb(db) -> list:
"""Get a list of all user addresses."""
with db.read_connection() as conn:
rows = conn.execute("SELECT addr from users").fetchall()
return [x[0] for x in rows]
def split_and_unescape(s): def split_and_unescape(s):
@@ -144,9 +103,8 @@ def split_and_unescape(s):
class AuthDictProxy(DictProxy): class AuthDictProxy(DictProxy):
def __init__(self, db, config): def __init__(self, config):
super().__init__() super().__init__()
self.db = db
self.config = config self.config = config
def handle_lookup(self, parts): def handle_lookup(self, parts):
@@ -158,14 +116,13 @@ class AuthDictProxy(DictProxy):
args = list(split_and_unescape(args)) args = list(split_and_unescape(args))
config = self.config config = self.config
db = self.db
reply_command = "F" reply_command = "F"
res = "" res = ""
if namespace == "shared": if namespace == "shared":
if type == "userdb": if type == "userdb":
user = args[0] user = args[0]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, config, user) res = lookup_userdb(config, user)
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
@@ -173,7 +130,7 @@ class AuthDictProxy(DictProxy):
elif type == "passdb": elif type == "passdb":
user = args[1] user = args[1]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = lookup_passdb(db, config, user, cleartext_password=args[0]) res = lookup_passdb(config, user, cleartext_password=args[0])
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
@@ -184,16 +141,21 @@ class AuthDictProxy(DictProxy):
def handle_iterate(self, parts): def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/ # example: I0\t0\tshared/userdb/
if parts[2] == "shared/userdb/": if parts[2] == "shared/userdb/":
db = self.db result = "".join(
result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db)) f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n" return f"{result}\n"
def iter_userdb(self) -> list:
"""Get a list of all user addresses."""
getuserpaths = self.config.mailboxes_dir.iterdir()
return [x.name for x in getuserpaths if "@" in x.name]
def main(): def main():
socket, cfgpath = sys.argv[1:] socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
db = Database(config.passdb_path)
dictproxy = AuthDictProxy(db=db, config=config) dictproxy = AuthDictProxy(config=config)
dictproxy.serve_forever_from_socket(socket) dictproxy.serve_forever_from_socket(socket)

View File

@@ -6,9 +6,7 @@ it will echo back any message that has non-empty text and also supports the /hel
import logging import logging
import os import os
import subprocess
import sys import sys
from pathlib import Path
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
@@ -80,23 +78,17 @@ def main():
bot = Bot(account, hooks) bot = Bot(account, hooks)
config = read_config(sys.argv[1]) config = read_config(sys.argv[1])
addr = "echo@" + config.mail_domain
# Create password file # Create password file
if bot.is_configured(): if bot.is_configured():
password = bot.account.get_config("mail_pw") password = bot.account.get_config("mail_pw")
else: else:
password = create_newemail_dict(config)["password"] password = create_newemail_dict(config)["password"]
Path("/run/echobot/password").write_text(password) config.set_user_password(addr, password)
# Give the user which doveauth runs as access to the password file.
subprocess.run(
["/usr/bin/setfacl", "-m", "user:vmail:r", "/run/echobot/password"],
check=True,
)
if not bot.is_configured(): if not bot.is_configured():
email = "echo@" + config.mail_domain bot.configure(addr, password)
bot.configure(email, password)
bot.run_forever() bot.run_forever()

View File

@@ -8,7 +8,6 @@ from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config
from chatmaild.database import Database
@pytest.fixture @pytest.fixture
@@ -54,13 +53,6 @@ def gencreds(maildomain):
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
@pytest.fixture @pytest.fixture
def maildata(request): def maildata(request):
try: try:

View File

@@ -25,14 +25,13 @@ def test_delete_skips_non_email_dir(db, example_config):
assert not list(userdir.iterdir()) assert not list(userdir.iterdir())
def test_delete_inactive_users(db, example_config): def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
def create_user(addr, last_login): def create_user(addr, last_login):
lookup_passdb(db, example_config, addr, "q9mr3faue") lookup_passdb(example_config, addr, "q9mr3faue")
md = example_config.get_user_maildir(addr) md = example_config.get_user_maildir(addr)
md.mkdir(parents=True)
md.joinpath("cur").mkdir() md.joinpath("cur").mkdir()
md.joinpath("cur", "something").mkdir() md.joinpath("cur", "something").mkdir()
write_last_login_to_userdir(md, timestamp=last_login) write_last_login_to_userdir(md, timestamp=last_login)
@@ -42,8 +41,6 @@ def test_delete_inactive_users(db, example_config):
for i in range(150): for i in range(150):
addr = f"oldold{i:03}@chat.example.org" addr = f"oldold{i:03}@chat.example.org"
create_user(addr, last_login=old) create_user(addr, last_login=old)
with db.read_connection() as conn:
assert conn.get_user(addr)
to_remove.append(addr) to_remove.append(addr)
remain = [] remain = []
@@ -57,17 +54,15 @@ def test_delete_inactive_users(db, example_config):
for addr in to_remove: for addr in to_remove:
assert example_config.get_user_maildir(addr).exists() assert example_config.get_user_maildir(addr).exists()
delete_inactive_users(db, example_config) delete_inactive_users(example_config)
for p in example_config.mailboxes_dir.iterdir(): for p in example_config.mailboxes_dir.iterdir():
assert not p.name.startswith("old") assert not p.name.startswith("old")
for addr in to_remove: for addr in to_remove:
assert not example_config.get_user_maildir(addr).exists() assert not example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert not conn.get_user(addr)
for addr in remain: for addr in remain:
assert example_config.get_user_maildir(addr).exists() userdir = example_config.get_user_maildir(addr)
with db.read_connection() as conn: assert userdir.exists()
assert conn.get_user(addr) assert userdir.joinpath("password").read_text()

View File

@@ -6,35 +6,35 @@ import traceback
import chatmaild.doveauth import chatmaild.doveauth
import pytest import pytest
from chatmaild.database import DBError
from chatmaild.doveauth import ( from chatmaild.doveauth import (
AuthDictProxy, AuthDictProxy,
get_user_data,
is_allowed_to_create, is_allowed_to_create,
iter_userdb,
lookup_passdb, lookup_passdb,
lookup_userdb,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
def test_basic(db, example_config): def test_basic(example_config):
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue") lookup_passdb(example_config, "asdf12345@chat.example.org", "q9mr3faue")
data = get_user_data(db, example_config, "asdf12345@chat.example.org") data = lookup_userdb(example_config, "asdf12345@chat.example.org")
assert data assert data
data2 = lookup_passdb( data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue" example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
) )
assert data == data2 assert data == data2
def test_iterate_addresses(db, example_config): def test_iterate_addresses(example_config):
addresses = [] addresses = []
for i in range(10): for i in range(10):
addresses.append(f"asdf1234{i}@chat.example.org") addresses.append(f"asdf1234{i}@chat.example.org")
lookup_passdb(db, example_config, addresses[-1], "q9mr3faue") lookup_passdb(example_config, addresses[-1], "q9mr3faue")
res = iter_userdb(db)
assert res == addresses dictproxy = AuthDictProxy(config=example_config)
res = dictproxy.iter_userdb()
assert set(res) == set(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -51,40 +51,27 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(db, example_config): def test_dont_overwrite_password_on_wrong_login(example_config):
"""Test that logging in with a different password doesn't create a new user""" """Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb( res = lookup_passdb(
db, example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
) )
assert res["password"] assert res["password"]
res2 = lookup_passdb(db, example_config, "newuser12@chat.example.org", "kajdslqwe") res2 = lookup_passdb(example_config, "newuser12@chat.example.org", "kajdslqwe")
# this function always returns a password hash, which is actually compared by dovecot. # this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert res["password"] == res2["password"]
def test_nocreate_file(db, monkeypatch, tmpdir, example_config): def test_nocreate_file(monkeypatch, tmpdir, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
lookup_passdb( lookup_passdb(example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik")
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik" assert not lookup_userdb(example_config, "newuser12@chat.example.org")
)
assert not get_user_data(db, example_config, "newuser12@chat.example.org")
def test_db_version(db): def test_handle_dovecot_request(example_config):
assert db.get_schema_version() == 1 dictproxy = AuthDictProxy(config=example_config)
def test_too_high_db_version(db):
with db.write_transaction() as conn:
conn.execute("PRAGMA user_version=%s;" % (999,))
with pytest.raises(DBError):
db.ensure_tables()
def test_handle_dovecot_request(db, example_config):
dictproxy = AuthDictProxy(db=db, config=example_config)
# Test that password can contain ", ', \ and / # Test that password can contain ", ', \ and /
msg = ( msg = (
@@ -100,8 +87,8 @@ def test_handle_dovecot_request(db, example_config):
assert userdata["password"].startswith("{SHA512-CRYPT}") assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog): def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
dictproxy = AuthDictProxy(db=db, config=example_config) dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n") rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
@@ -109,8 +96,8 @@ def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
assert not caplog.messages assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(db, example_config): def test_handle_dovecot_protocol_user_not_exists(example_config):
dictproxy = AuthDictProxy(db=db, config=example_config) dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO( rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n" b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
) )
@@ -119,29 +106,29 @@ def test_handle_dovecot_protocol_user_not_exists(db, example_config):
assert wfile.getvalue() == b"N\n" assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(db, gencreds, example_config): def test_handle_dovecot_protocol_iterate(gencreds, example_config):
dictproxy = AuthDictProxy(db=db, config=example_config) dictproxy = AuthDictProxy(config=example_config)
lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue") lookup_passdb(example_config, "asdf00000@chat.example.org", "q9mr3faue")
lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue") lookup_passdb(example_config, "asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/") rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
lines = wfile.getvalue().decode("ascii").split("\n") lines = wfile.getvalue().decode("ascii").split("\n")
assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t" assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t" assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
assert not lines[2] assert not lines[2]
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config): def test_50_concurrent_lookups_different_accounts(gencreds, example_config):
num_threads = 50 num_threads = 50
req_per_thread = 5 req_per_thread = 5
results = queue.Queue() results = queue.Queue()
def lookup(db): def lookup():
for i in range(req_per_thread): for i in range(req_per_thread):
addr, password = gencreds() addr, password = gencreds()
try: try:
lookup_passdb(db, example_config, addr, password) lookup_passdb(example_config, addr, password)
except Exception: except Exception:
results.put(traceback.format_exc()) results.put(traceback.format_exc())
else: else:
@@ -149,7 +136,7 @@ def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
threads = [] threads = []
for i in range(num_threads): for i in range(num_threads):
thread = threading.Thread(target=lookup, args=(db,), daemon=True) thread = threading.Thread(target=lookup, daemon=True)
threads.append(thread) threads.append(thread)
print(f"created {num_threads} threads, starting them and waiting for results") print(f"created {num_threads} threads, starting them and waiting for results")