auth.lua 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. local json = require "json"
  2. local auth_url = "http://app:3001/internal/dovecot/auth"
  3. local secret_file = "/run/secrets/dovecot_auth_secret"
  4. local readonly_acl_group = "mailhub_webmail_readonly"
  5. local http_client
  6. local shared_secret
  7. local function read_secret(path)
  8. local file = io.open(path, "r")
  9. if file == nil then
  10. error("Dovecot authentication secret is unavailable")
  11. end
  12. local value = file:read("*a")
  13. file:close()
  14. value = string.gsub(value or "", "^%s+", "")
  15. value = string.gsub(value, "%s+$", "")
  16. if #value < 32 or #value > 512 or string.find(value, "%s") ~= nil then
  17. error("Dovecot authentication secret is invalid")
  18. end
  19. return value
  20. end
  21. function script_init()
  22. shared_secret = read_secret(secret_file)
  23. http_client = dovecot.http.client {
  24. auto_retry = "no",
  25. request_max_attempts = 1,
  26. connect_timeout = "1s",
  27. request_timeout = "30s",
  28. request_absolute_timeout = "30s"
  29. }
  30. return 0
  31. end
  32. local function failure(result)
  33. return result, nil
  34. end
  35. local function valid_user(value)
  36. if type(value) ~= "string" or #value == 0 or #value > 320 then
  37. return false
  38. end
  39. if string.find(value, "/", 1, true) ~= nil
  40. or string.find(value, "\\", 1, true) ~= nil
  41. or string.find(value, "\0", 1, true) ~= nil
  42. or string.find(value, "%s") ~= nil then
  43. return false
  44. end
  45. return string.find(value, "^[^@]+@[^@]+%.[^@]+$") ~= nil
  46. end
  47. local function first_nonempty_string(...)
  48. for index = 1, select("#", ...) do
  49. local value = select(index, ...)
  50. if value ~= nil then
  51. local normalized = tostring(value)
  52. if normalized ~= "" then
  53. return normalized
  54. end
  55. end
  56. end
  57. return ""
  58. end
  59. function auth_password_verify(request, password)
  60. local protocol = string.lower(first_nonempty_string(request.protocol, request.service))
  61. local remote_ip = first_nonempty_string(
  62. request.remote_ip,
  63. request.real_remote_ip,
  64. request.rip,
  65. request.real_rip
  66. )
  67. if protocol ~= "imap" and protocol ~= "pop3" then
  68. return failure(dovecot.auth.PASSDB_RESULT_USER_DISABLED)
  69. end
  70. local http_request = http_client:request {
  71. url = auth_url,
  72. method = "POST"
  73. }
  74. http_request:add_header("content-type", "application/json")
  75. http_request:add_header("authorization", "Bearer " .. shared_secret)
  76. http_request:add_header("connection", "close")
  77. http_request:set_payload(json.encode {
  78. username = request.user,
  79. password = password,
  80. service = protocol,
  81. remoteIp = remote_ip
  82. })
  83. local submitted, response = pcall(function()
  84. return http_request:submit()
  85. end)
  86. if not submitted then
  87. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  88. end
  89. local status = response:status()
  90. if status ~= 200 then
  91. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  92. end
  93. local decoded, payload = pcall(json.decode, response:payload())
  94. if not decoded or type(payload) ~= "table" then
  95. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  96. end
  97. if payload.authenticated == false then
  98. return failure(dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH)
  99. end
  100. if payload.authenticated ~= true then
  101. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  102. end
  103. if not valid_user(payload.user) then
  104. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  105. end
  106. local fields = { user = string.lower(payload.user) }
  107. if payload.aclGroups ~= nil then
  108. if payload.aclGroups ~= readonly_acl_group then
  109. return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
  110. end
  111. fields.userdb_acl_groups = readonly_acl_group
  112. -- A private namespace grants implicit owner rights, including root-level
  113. -- mailbox creation. Public type removes those implicit rights; the fixed
  114. -- ACL group below grants back only lookup/read/Seen.
  115. fields["userdb_namespace/inbox/type"] = "public"
  116. end
  117. return dovecot.auth.PASSDB_RESULT_OK, fields
  118. end