feat: distinguish AUTHENTICATION_FAILED/UNAVAILABLE login failures

on doveauth.py being down, users would "AUTHENTIFICATIONFAILED"
while they now get:

    IMAP failed to login as tovnlbmsz@_lua0.localchat: no response:
    code: None, info: Some("[UNAVAILABLE] Temporary authentication
    failure. [lua0-localchat:2026-09-01 11:00:17]")
This commit is contained in:
holger krekel
2026-09-01 22:56:07 +02:00
parent 2d0fc2e70e
commit 2eb0ef5f9e
2 changed files with 22 additions and 8 deletions
+6 -2
View File
@@ -31,7 +31,7 @@ end
local function create(user, password) local function create(user, password)
local request = http_client:request({url = create_url, method = "POST"}) local request = http_client:request({url = create_url, method = "POST"})
request:set_payload(user .. "\t" .. password) request:set_payload(user .. "\t" .. password)
return request:submit():status() == 200 return request:submit():status()
end end
-- Entry points called by dovecot -- Entry points called by dovecot
@@ -54,7 +54,11 @@ function auth_password_verify(req, password)
end end
local hash = password_hash(req.user) local hash = password_hash(req.user)
if hash == nil then if hash == nil then
if not create(req.user, password) then -- doveauth refuses with 4xx; dovecot reports its own failures as 9000 and up
local status = create(req.user, password)
if status >= 500 then
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, {}
elseif status ~= 200 then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {} return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end end
elseif req:password_verify(hash, password) ~= 1 then elseif req:password_verify(hash, password) ~= 1 then
+16 -6
View File
@@ -8,7 +8,7 @@ from cmdeploy.basedeploy import get_resource
USER1 = "user12345@chat.example.org" USER1 = "user12345@chat.example.org"
USER2 = "newuser12@chat.example.org" USER2 = "newuser12@chat.example.org"
OK, UNKNOWN, MISMATCH = 1, -2, -3 OK, UNKNOWN, MISMATCH, INTERNAL = 1, -2, -3, -4
DOVECOT_MOCKS = """ DOVECOT_MOCKS = """
create_status = 200 create_status = 200
@@ -18,6 +18,7 @@ dovecot = {
PASSDB_RESULT_OK = OK, PASSDB_RESULT_OK = OK,
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN, PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH, PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
PASSDB_RESULT_INTERNAL_FAILURE = INTERNAL,
USERDB_RESULT_OK = OK, USERDB_RESULT_OK = OK,
USERDB_RESULT_USER_UNKNOWN = UNKNOWN, USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
}, },
@@ -40,7 +41,8 @@ dovecot = {
def load_authlua(lua, config): def load_authlua(lua, config):
lua.g.OK, lua.g.UNKNOWN, lua.g.MISMATCH = OK, UNKNOWN, MISMATCH lua.g.OK, lua.g.UNKNOWN = OK, UNKNOWN
lua.g.MISMATCH, lua.g.INTERNAL = MISMATCH, INTERNAL
lua.rt.execute(DOVECOT_MOCKS) lua.rt.execute(DOVECOT_MOCKS)
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text()) template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
lua.rt.execute(template.render(config=config)) lua.rt.execute(template.render(config=config))
@@ -147,14 +149,22 @@ def test_unknown_address_is_created_via_endpoint(authlua, request_for):
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create" assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
# a policy refusal and a doveauth that is down are both fail-closed # doveauth refusing is the user's problem, doveauth failing is ours
@pytest.mark.parametrize("status", [403, 0]) @pytest.mark.parametrize("status", [400, 403, 404])
def test_creation_that_is_not_answered_with_200_is_user_unknown( def test_creation_refused_by_doveauth_is_user_unknown(authlua, request_for, status):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN
# 9003 is dovecot's own CONNECT_FAILED, what a stopped doveauth actually yields
@pytest.mark.parametrize("status", [500, 502, 9003, 9005])
def test_creation_that_doveauth_could_not_answer_is_internal_failure(
authlua, request_for, status authlua, request_for, status
): ):
authlua.g.create_status = status authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass") res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN assert res == INTERNAL
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user): def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):