Skip to content
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## untagged

- Enable invite-only chatmail relays with invite tokens
that can override disabled account creation
([#600](https://github.com/chatmail/relay/pull/600))

- dovecot: keep mailbox index only in memory to avoid unnecessary disc usage
([#632](https://github.com/chatmail/relay/pull/632))

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,23 @@ Fresh chatmail addresses have a mailbox directory that contains:
will typically be empty unless the user of that address hasn't been online
for a while.

## Restrict address creation

## Emergency Commands to disable automatic address creation
### Only allow new addresses with an invite token

To restrict address creation for anyone who doesn't have the invite link/QR code:

1. Use the `invite_token` option to add
one or more tokens of your choice to `chatmail.ini`:
`invite_token = s3cr3t privil3g3`
- (recommendation: choose 9 or more letters, or it will be easily bruteforced)
2. Run `scripts/cmdeploy run`
3. Distribute a `dcaccount` invite link/QR code
(like the one on your web page)
with one of your invite tokens added at the end,
for example: `dcaccount:https://example.org/new?s3cr3t`

### Emergency Command to disable automatic address creation

If you need to stop address creation,
e.g. because some script is wildly creating addresses,
Expand Down
1 change: 1 addition & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def __init__(self, inipath, params):
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.invite_token = params.get("invite_token", "")
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "")
Expand Down
13 changes: 12 additions & 1 deletion chatmaild/src/chatmaild/doveauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,19 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
if os.path.exists(NOCREATE_FILE):
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
return False
password_length = len(cleartext_password)
if config.invite_token:
for inv_token in config.invite_token.split():
if cleartext_password.startswith(inv_token):
password_length = len(cleartext_password) - len(inv_token)
break
else:
logging.warning(
"blocked account creation because password didn't contain invite token(s)."
)
return False

if len(cleartext_password) < config.password_min_length:
if password_length < config.password_min_length:
logging.warning(
"Password needs to be at least %s characters long",
config.password_min_length,
Expand Down
7 changes: 6 additions & 1 deletion chatmaild/src/chatmaild/newemail.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""CGI script for creating new accounts."""

import json
import os
import random
import secrets
import string
Expand All @@ -20,7 +21,11 @@ def create_newemail_dict(config: Config):
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3)
)
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
redirect_uri = os.getenv("REQUEST_URI", "/new")
invite_token = "" if redirect_uri == "/new" else redirect_uri[5:]
return dict(
email=f"{user}@{config.mail_domain}", password=f"{invite_token}{password}"
)


def print_new_account():
Expand Down
38 changes: 32 additions & 6 deletions chatmaild/src/chatmaild/tests/test_doveauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,38 @@ def test_dont_overwrite_password_on_wrong_login(dictproxy):
assert res["password"] == res2["password"]


def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
p = tmpdir.join("nocreate")
p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
dictproxy.lookup_passdb("[email protected]", "zequ0Aimuchoodaechik")
assert not dictproxy.lookup_userdb("[email protected]")
@pytest.mark.parametrize(
["nocreate_file", "account", "invite_token", "password"],
[
(False, True, "asdf", "asdfasdmaimfelsgwerw"),
(False, False, "asdf", "z9873240187420913798"),
(False, True, "", "dsaiujfw9fjiwf9w"),
(False, False, "asdf", "z987324018742asdf0913798"),
(False, True, "as df", "asj0wiefkj0ofkeefok"),
(False, True, "as df", "dfj0wiefkj0ofkeefok"),
(False, False, "as df", "j0wiefkj0ofas dfkeefok"),
(True, False, "asdf", "asdfmosadkdkfwdofkw"),
(True, False, "asdf", "z9873240187420913798"),
(True, False, "", "dsaiujfw9fjiwf9w"),
],
)
def test_nocreate_file(
monkeypatch,
tmpdir,
dictproxy,
example_config,
nocreate_file: bool,
account: bool,
invite_token: str,
password: str,
):
if nocreate_file:
p = tmpdir.join("nocreate")
p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
example_config.invite_token = invite_token
dictproxy.lookup_passdb("[email protected]", password)
assert bool(dictproxy.lookup_userdb("[email protected]")) == account


def test_handle_dovecot_request(dictproxy):
Expand Down
5 changes: 3 additions & 2 deletions cmdeploy/src/cmdeploy/nginx/nginx.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ http {
if ($request_method = GET) {
# Redirect to Delta Chat,
# which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new;
return 301 dcaccount:https://{{ config.domain_name }}$request_uri;
}

fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
fastcgi_param QUERY_STRING $query_string;
}

# Old URL for compatibility with e.g. printed QR codes.
Expand All @@ -100,7 +101,7 @@ http {
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new;
return 301 dcaccount:https://{{ config.domain_name }}$request_uri;
}

fastcgi_pass unix:/run/fcgiwrap.socket;
Expand Down
5 changes: 5 additions & 0 deletions www/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html).
{% endif %}

{% if not config.invite_token %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>

If you are viewing this page on a different device
Expand All @@ -23,6 +24,10 @@ you can also **scan this QR code** with Delta Chat:
🐣 **Choose** your Avatar and Name

💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
{% else %}
**To join this instance, you need an invite link or QR code -
ask the admin for an invite.**
{% endif %}

{% if config.mail_domain != "nine.testrun.org" %}
<div class="experimental">Note: this is only a temporary development chatmail service</div>
Expand Down