From eddfadaf7fda65d1f3f2abb1e8c15bc59aa71812 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 22 Jul 2024 16:55:24 +0200 Subject: [PATCH] move passwords to file in user maildir --- chatmaild/src/chatmaild/config.py | 17 ++++ .../src/chatmaild/delete_inactive_users.py | 24 +----- chatmaild/src/chatmaild/doveauth.py | 84 +++++-------------- chatmaild/src/chatmaild/echo.py | 14 +--- chatmaild/src/chatmaild/tests/plugin.py | 8 -- .../tests/test_delete_inactive_users.py | 17 ++-- .../src/chatmaild/tests/test_doveauth.py | 79 ++++++++--------- 7 files changed, 85 insertions(+), 158 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 5d33c646..30f73789 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -1,3 +1,5 @@ +import os +import sys from pathlib import Path import iniconfig @@ -50,6 +52,21 @@ class Config: res["password"] = enc_password 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): """Write out default config file, using the specified config value overrides.""" diff --git a/chatmaild/src/chatmaild/delete_inactive_users.py b/chatmaild/src/chatmaild/delete_inactive_users.py index ad94ab48..c253653f 100644 --- a/chatmaild/src/chatmaild/delete_inactive_users.py +++ b/chatmaild/src/chatmaild/delete_inactive_users.py @@ -7,35 +7,17 @@ import sys import time from .config import read_config -from .database import Database 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 - 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(): if get_last_login_from_userdir(userdir) < cutoff_date: - remove(userdir) - - clear_pending() + shutil.rmtree(userdir, ignore_errors=True) def main(): (cfgpath,) = sys.argv[1:] config = read_config(cfgpath) - db = Database(config.passdb_path) - delete_inactive_users(db, config) + delete_inactive_users(config) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index 7c6b9582..eecfad3c 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -3,19 +3,13 @@ import json import logging import os import sys -from pathlib import Path from .config import Config, read_config -from .database import Database from .dictproxy import DictProxy NOCREATE_FILE = "/etc/chatmail-nocreate" -class UnknownCommand(ValueError): - """dictproxy handler received an unkown command""" - - def encrypt_password(password: str): # https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ passhash = crypt.crypt(password, crypt.METHOD_SHA512) @@ -60,61 +54,26 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool: return True -def get_user_data(db, config: Config, user, conn=None): - if user == f"echo@{config.mail_domain}": - return config.get_user_dict(user) - - if conn is None: - with db.read_connection() as conn: - result = conn.get_user(user) - else: - result = conn.get_user(user) - - if result: +def lookup_userdb(config: Config, user): + userdir = config.get_user_maildir(user) + password_path = userdir.joinpath("password") + result = {} + if password_path.exists(): + result = dict(addr=user, password=password_path.read_text()) result.update(config.get_user_dict(user)) return result -def lookup_userdb(db, config: Config, user): - return get_user_data(db, 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) +def lookup_passdb(config: Config, user, cleartext_password): + userdata = lookup_userdb(config, user) if userdata: return userdata if not is_allowed_to_create(config, user, cleartext_password): return - # reading and writing user data needs to be atomic - # to allow concurrent logins to succeed. - with db.write_transaction() as conn: - 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] + enc_password = encrypt_password(cleartext_password) + config.set_user_password(user, enc_password=enc_password) + return config.get_user_dict(user, enc_password=enc_password) def split_and_unescape(s): @@ -144,9 +103,8 @@ def split_and_unescape(s): class AuthDictProxy(DictProxy): - def __init__(self, db, config): + def __init__(self, config): super().__init__() - self.db = db self.config = config def handle_lookup(self, parts): @@ -158,14 +116,13 @@ class AuthDictProxy(DictProxy): args = list(split_and_unescape(args)) config = self.config - db = self.db reply_command = "F" res = "" if namespace == "shared": if type == "userdb": user = args[0] if user.endswith(f"@{config.mail_domain}"): - res = lookup_userdb(db, config, user) + res = lookup_userdb(config, user) if res: reply_command = "O" else: @@ -173,7 +130,7 @@ class AuthDictProxy(DictProxy): elif type == "passdb": user = args[1] 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: reply_command = "O" else: @@ -184,16 +141,21 @@ class AuthDictProxy(DictProxy): def handle_iterate(self, parts): # example: I0\t0\tshared/userdb/ if parts[2] == "shared/userdb/": - db = self.db - result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db)) + result = "".join( + f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb() + ) 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(): socket, cfgpath = sys.argv[1:] 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) diff --git a/chatmaild/src/chatmaild/echo.py b/chatmaild/src/chatmaild/echo.py index 765e2dbd..980b1dc6 100644 --- a/chatmaild/src/chatmaild/echo.py +++ b/chatmaild/src/chatmaild/echo.py @@ -6,9 +6,7 @@ it will echo back any message that has non-empty text and also supports the /hel import logging import os -import subprocess import sys -from pathlib import Path from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events @@ -80,23 +78,17 @@ def main(): bot = Bot(account, hooks) config = read_config(sys.argv[1]) + addr = "echo@" + config.mail_domain # Create password file if bot.is_configured(): password = bot.account.get_config("mail_pw") else: password = create_newemail_dict(config)["password"] - Path("/run/echobot/password").write_text(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, - ) + config.set_user_password(addr, password) if not bot.is_configured(): - email = "echo@" + config.mail_domain - bot.configure(email, password) + bot.configure(addr, password) bot.run_forever() diff --git a/chatmaild/src/chatmaild/tests/plugin.py b/chatmaild/src/chatmaild/tests/plugin.py index c122c725..16dcb07d 100644 --- a/chatmaild/src/chatmaild/tests/plugin.py +++ b/chatmaild/src/chatmaild/tests/plugin.py @@ -8,7 +8,6 @@ from pathlib import Path import pytest from chatmaild.config import read_config, write_initial_config -from chatmaild.database import Database @pytest.fixture @@ -54,13 +53,6 @@ def gencreds(maildomain): 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 def maildata(request): try: diff --git a/chatmaild/src/chatmaild/tests/test_delete_inactive_users.py b/chatmaild/src/chatmaild/tests/test_delete_inactive_users.py index cd95d179..ae1bf8eb 100644 --- a/chatmaild/src/chatmaild/tests/test_delete_inactive_users.py +++ b/chatmaild/src/chatmaild/tests/test_delete_inactive_users.py @@ -25,14 +25,13 @@ def test_delete_skips_non_email_dir(db, example_config): assert not list(userdir.iterdir()) -def test_delete_inactive_users(db, example_config): +def test_delete_inactive_users(example_config): new = time.time() old = new - (example_config.delete_inactive_users_after * 86400) - 1 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.mkdir(parents=True) md.joinpath("cur").mkdir() md.joinpath("cur", "something").mkdir() 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): addr = f"oldold{i:03}@chat.example.org" create_user(addr, last_login=old) - with db.read_connection() as conn: - assert conn.get_user(addr) to_remove.append(addr) remain = [] @@ -57,17 +54,15 @@ def test_delete_inactive_users(db, example_config): for addr in to_remove: 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(): assert not p.name.startswith("old") for addr in to_remove: 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: - assert example_config.get_user_maildir(addr).exists() - with db.read_connection() as conn: - assert conn.get_user(addr) + userdir = example_config.get_user_maildir(addr) + assert userdir.exists() + assert userdir.joinpath("password").read_text() diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 503a90fd..d5a2e314 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -6,35 +6,35 @@ import traceback import chatmaild.doveauth import pytest -from chatmaild.database import DBError from chatmaild.doveauth import ( AuthDictProxy, - get_user_data, is_allowed_to_create, - iter_userdb, lookup_passdb, + lookup_userdb, ) from chatmaild.newemail import create_newemail_dict -def test_basic(db, example_config): - lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue") - data = get_user_data(db, example_config, "asdf12345@chat.example.org") +def test_basic(example_config): + lookup_passdb(example_config, "asdf12345@chat.example.org", "q9mr3faue") + data = lookup_userdb(example_config, "asdf12345@chat.example.org") assert data data2 = lookup_passdb( - db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue" + example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue" ) assert data == data2 -def test_iterate_addresses(db, example_config): +def test_iterate_addresses(example_config): addresses = [] for i in range(10): addresses.append(f"asdf1234{i}@chat.example.org") - lookup_passdb(db, example_config, addresses[-1], "q9mr3faue") - res = iter_userdb(db) - assert res == addresses + lookup_passdb(example_config, addresses[-1], "q9mr3faue") + + dictproxy = AuthDictProxy(config=example_config) + res = dictproxy.iter_userdb() + assert set(res) == set(addresses) 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""" res = lookup_passdb( - db, example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" + example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" ) 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. 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.write("") monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) - lookup_passdb( - db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik" - ) - assert not get_user_data(db, example_config, "newuser12@chat.example.org") + lookup_passdb(example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik") + assert not lookup_userdb(example_config, "newuser12@chat.example.org") -def test_db_version(db): - assert db.get_schema_version() == 1 - - -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) +def test_handle_dovecot_request(example_config): + dictproxy = AuthDictProxy(config=example_config) # Test that password can contain ", ', \ and / msg = ( @@ -100,8 +87,8 @@ def test_handle_dovecot_request(db, example_config): assert userdata["password"].startswith("{SHA512-CRYPT}") -def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog): - dictproxy = AuthDictProxy(db=db, config=example_config) +def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog): + dictproxy = AuthDictProxy(config=example_config) rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n") wfile = io.BytesIO() 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 -def test_handle_dovecot_protocol_user_not_exists(db, example_config): - dictproxy = AuthDictProxy(db=db, config=example_config) +def test_handle_dovecot_protocol_user_not_exists(example_config): + dictproxy = AuthDictProxy(config=example_config) rfile = io.BytesIO( 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" -def test_handle_dovecot_protocol_iterate(db, gencreds, example_config): - dictproxy = AuthDictProxy(db=db, config=example_config) - lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue") - lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue") +def test_handle_dovecot_protocol_iterate(gencreds, example_config): + dictproxy = AuthDictProxy(config=example_config) + lookup_passdb(example_config, "asdf00000@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/") wfile = io.BytesIO() dictproxy.loop_forever(rfile, wfile) lines = wfile.getvalue().decode("ascii").split("\n") - assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t" - assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t" + assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines + assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines 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 req_per_thread = 5 results = queue.Queue() - def lookup(db): + def lookup(): for i in range(req_per_thread): addr, password = gencreds() try: - lookup_passdb(db, example_config, addr, password) + lookup_passdb(example_config, addr, password) except Exception: results.put(traceback.format_exc()) else: @@ -149,7 +136,7 @@ def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config): 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) print(f"created {num_threads} threads, starting them and waiting for results")