Add option for hosting each realm on its own subdomain.

This adds support for running a Zulip production server with each
realm on its own unique subdomain, e.g. https://realm_name.example.com.

This patch includes a ton of important features:
* Configuring the Zulip sesion middleware to issue cookier correctly
  for the subdomains case.
* Throwing an error if the user tries to visit an invalid subdomain.
* Runs a portion of the Casper tests with REALMS_HAVE_SUBDOMAINS
  enabled to test the subdomain signup process.
* Updating our integrations documentation to refer to the current subdomain.
* Enforces that users can only login to the subdomain of their realm
  (but does not restrict the API; that will be tightened in a future commit).

Note that toggling settings.REALMS_HAVE_SUBDOMAINS on a live server is
not supported without manual intervention (the main problem will be
adding "subdomain" values for all the existing realms).

[substantially modified by tabbott as part of merging]
This commit is contained in:
hackerkid
2016-07-19 18:05:08 +05:30
committed by Tim Abbott
parent 15f6cc7c84
commit ea39fb2556
25 changed files with 442 additions and 82 deletions

View File

@@ -1,3 +1,4 @@
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var common = (function () {
var exports = {};
@@ -83,7 +84,13 @@ exports.then_log_in = function (credentials) {
};
exports.start_and_log_in = function (credentials, viewport) {
casper.start('http://127.0.0.1:9981/accounts/login', function () {
var log_in_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
log_in_url = "http://zulip.zulipdev.com:9981/accounts/login";
} else {
log_in_url = "http://localhost:9981/accounts/login";
}
casper.start(log_in_url, function () {
exports.initialize_casper(viewport);
log_in(credentials);
});

View File

@@ -2,9 +2,20 @@ var common = require('../casper_lib/common.js').common;
var email = 'alice@test.example.com';
var domain = 'test.example.com';
var subdomain = 'testsubdomain';
var organization_name = 'Awesome Organization';
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var host;
var realm_host;
casper.start('http://127.0.0.1:9981/create_realm/');
if (REALMS_HAVE_SUBDOMAINS) {
host = 'zulipdev.com:9981';
realm_host = subdomain + '.' + host;
} else {
host = realm_host = 'localhost:9981';
}
casper.start('http://' + host + '/create_realm/');
casper.then(function () {
// Submit the email for realm creation
@@ -21,12 +32,12 @@ casper.then(function () {
});
// Special endpoint enabled only during tests for extracting confirmation key
casper.thenOpen('http://127.0.0.1:9981/confirmation_key/');
casper.thenOpen('http://' + host + '/confirmation_key/');
// Open the confirmation URL
casper.then(function () {
var confirmation_key = JSON.parse(this.getPageContent()).confirmation_key;
var confirmation_url = 'http://127.0.0.1:9981/accounts/do_confirm/' + confirmation_key;
var confirmation_url = 'http://' + host + '/accounts/do_confirm/' + confirmation_key;
this.thenOpen(confirmation_url);
});
@@ -47,16 +58,26 @@ casper.then(function () {
casper.then(function () {
this.waitForSelector('form[action^="/accounts/register/"]', function () {
this.fill('form[action^="/accounts/register/"]', {
full_name: 'Alice',
realm_name: organization_name,
password: 'password',
terms: true
}, true);
if (REALMS_HAVE_SUBDOMAINS) {
this.fill('form[action^="/accounts/register/"]', {
full_name: 'Alice',
realm_name: organization_name,
realm_subdomain: subdomain,
password: 'password',
terms: true
}, true);
} else {
this.fill('form[action^="/accounts/register/"]', {
full_name: 'Alice',
realm_name: organization_name,
password: 'password',
terms: true
}, true);
}
});
this.waitWhileSelector('form[action^="/accounts/register/"]', function () {
casper.test.assertUrlMatch('http://127.0.0.1:9981/invite/', 'Invite more users page loaded');
casper.test.assertUrlMatch(realm_host + '/invite/', 'Invite more users page loaded');
});
});
@@ -75,7 +96,7 @@ casper.then(function () {
});
this.waitWhileSelector('#submit_invitation', function () {
this.test.assertUrlMatch('http://127.0.0.1:9981/', 'Realm created and logged in');
this.test.assertUrlMatch(realm_host, 'Realm created and logged in');
});
});

View File

@@ -1,7 +1,14 @@
var common = require('../casper_lib/common.js').common;
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var realm_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
realm_url = "http://zulip.zulipdev.com:9981/";
} else {
realm_url = "http://localhost:9981/";
}
// Start of test script.
casper.start('http://127.0.0.1:9981/', common.initialize_casper);
casper.start(realm_url, common.initialize_casper);
casper.then(function () {
casper.test.assertHttpStatus(302);

View File

@@ -1,5 +1,6 @@
var common = require('../casper_lib/common.js').common;
var test_credentials = require('../../var/casper/test_credentials.js').test_credentials;
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
common.start_and_log_in();
@@ -166,7 +167,14 @@ casper.waitForSelector("#default_language", function () {
casper.test.info("Opening German page through i18n url.");
});
casper.thenOpen('http://127.0.0.1:9981/de/#settings');
var settings_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
settings_url = 'http://zulip.zulipdev.com:9981/de/#settings';
} else {
settings_url = 'http://localhost:9981/de/#settings';
}
casper.thenOpen(settings_url);
casper.waitForSelector("#settings-change-box", function check_url_preference() {
casper.test.info("Checking the i18n url language precedence.");

View File

@@ -144,7 +144,7 @@ casper.then(function () {
casper.waitForSelector('.admin-emoji-form', function () {
casper.fill('form.admin-emoji-form', {
'name': 'MouseFace',
'url': 'http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png'
'url': 'http://localhost:9991/static/images/integrations/logos/jenkins.png'
});
casper.click('form.admin-emoji-form input.button');
});
@@ -159,7 +159,7 @@ casper.then(function () {
casper.then(function () {
casper.waitForSelector('.emoji_row', function () {
casper.test.assertSelectorHasText('.emoji_row .emoji_name', 'MouseFace');
casper.test.assertExists('.emoji_row img[src="http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png"]');
casper.test.assertExists('.emoji_row img[src="http://localhost:9991/static/images/integrations/logos/jenkins.png"]');
casper.click('.emoji_row button.delete');
});
});

View File

@@ -64,8 +64,8 @@ def server_is_up(server):
except:
return False
def run_tests(files):
# type: (Iterable[str]) -> None
def run_tests(realms_have_subdomains, files):
# type: (bool, Iterable[str]) -> None
test_files = []
for file in files:
if not os.path.exists(file):
@@ -78,7 +78,7 @@ def run_tests(files):
if options.remote_debug:
remote_debug = "--remote-debugger-port=7777 --remote-debugger-autorun=yes"
cmd = "frontend_tests/casperjs/bin/casperjs %s test " % (remote_debug,)
cmd = "frontend_tests/casperjs/bin/casperjs %s test --subdomains=%s " % (remote_debug, realms_have_subdomains,)
if test_files:
cmd += ' '.join(test_files)
else:
@@ -115,5 +115,13 @@ Oops, the frontend tests failed. Tips for debugging:
sys.exit(ret)
run_tests(args)
# Run tests with REALMS_HAVE_SUBDOMAINS set to True
run_tests(False, args)
os.environ["EXTERNAL_HOST"] = "zulipdev.com:9981"
os.environ["REALMS_HAVE_SUBDOMAINS"] = "True"
# Run tests with REALMS_HAVE_SUBDOMAINS set to True
if len(args) == 0:
run_tests(True, ["00-realm-creation.js", "01-login.js", "02-site.js"])
else:
run_tests(True, args)
sys.exit(0)

View File

@@ -0,0 +1,9 @@
{% extends "zerver/portico.html" %}
{% block portico_content %}
<h3>{{ _('Organization Does Not Exist') }}</h3>
<p>{{ _('Hi there! Thank you for your interest in Zulip') }}.</p>
<p>{% trans %}There is no Zulip organization hosted at this subdomain{% endtrans %}.</p>
{% endblock %}

View File

@@ -33,6 +33,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
<input type='text' disabled="true" placeholder="{{ email }}" />
</div>
</div>
<div class="control-group">
<label for="id_full_name" class="control-label">{{ _('Full name') }}</label>
<div class="controls">
@@ -51,7 +52,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
</div>
</div>
{% if creating_new_team %}
{% if creating_new_team %}
<div class="control-group">
<label for="id_team_name" class="control-label">{{ _('Organization name') }}</label>
<div class="controls">
@@ -65,7 +66,24 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
{% endif %}
<br /><span class="small">{{ _('You can change this later on the admin page.') }}</span>
</div>
</div>
{% if realms_have_subdomains %}
<div class="control-group">
<label for="id_team_subdomain" class="control-label">{{ _('Subdomain') }}</label>
<div class="controls">
<input id="id_team_subdomain" class="required" type="text"
placeholder="{{ _("E.g. acme") }}"
name="realm_subdomain" maxlength="40" /><b> .{{ external_host }}</b>
{% if form.realm_subdomain.errors %}
{% for error in form.realm_subdomain.errors %}
<div class="alert alert-error">{{ error }}</div>
{% endfor %}
{% endif %}
<br /><span class="small"><p>{% trans %}The address you'll use to sign in to your organization.{% endtrans %}.</p></span>
</div>
</div>
{% endif %}
{% endif %}
{% if password_auth_enabled %}

View File

@@ -14,7 +14,7 @@ from django.utils.timezone import now
from django.conf import settings
from zerver.lib.queue import queue_json_publish
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.utils import statsd
from zerver.lib.utils import statsd, get_subdomain, check_subdomain
from zerver.exceptions import RateLimited
from zerver.lib.rate_limiter import incr_ratelimit, is_ratelimited, \
api_calls_left
@@ -279,7 +279,7 @@ def logged_in_and_active(request):
return False
if request.user.realm.deactivated:
return False
return True
return check_subdomain(get_subdomain(request), request.user.realm.subdomain)
# Based on Django 1.8's @login_required
def zulip_login_required(function=None,

View File

@@ -10,6 +10,8 @@ from django.db.models.query import QuerySet
from jinja2 import Markup as mark_safe
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from zerver.models import resolve_subdomain_to_realm
from zerver.lib.utils import get_subdomain, check_subdomain
import logging
@@ -23,6 +25,11 @@ from six import text_type
SIGNUP_STRING = u'Your e-mail does not match any existing open organization. ' + \
u'Use a different e-mail address, or contact %s with questions.' % (settings.ZULIP_ADMINISTRATOR,)
def subdomain_unavailable(subdomain):
# type: (text_type) -> text_type
return u"The subdomain '%s' is not available. Please choose another one." % (subdomain)
if settings.SHOW_OSS_ANNOUNCEMENT:
SIGNUP_STRING = u'Your e-mail does not match any existing organization. <br />' + \
u"The zulip.com service is not taking new customer teams. <br /> " + \
@@ -32,7 +39,10 @@ if settings.SHOW_OSS_ANNOUNCEMENT:
MIT_VALIDATION_ERROR = u'That user does not exist at MIT or is a ' + \
u'<a href="https://ist.mit.edu/email-lists">mailing list</a>. ' + \
u'If you want to sign up an alias for Zulip, ' + \
u'<a href="mailto:"' + settings.ZULIP_ADMINISTRATOR + '">contact us</a>.'
u'<a href="mailto:support@zulipchat.com">contact us</a>.'
WRONG_SUBDOMAIN_ERROR = "Your Zulip account is not a member of the " + \
"organization associated with this subdomain. " + \
"Please contact %s with any questions!" % (settings.ZULIP_ADMINISTRATOR,)
def get_registration_string(domain):
# type: (text_type) -> text_type
@@ -73,10 +83,18 @@ class RegistrationForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput, max_length=100,
required=False)
realm_name = forms.CharField(max_length=100, required=False)
realm_subdomain = forms.CharField(max_length=40, required=False)
if settings.TERMS_OF_SERVICE:
terms = forms.BooleanField(required=True)
def clean_realm_subdomain(self):
# type: () -> str
data = self.cleaned_data['realm_subdomain']
realm = resolve_subdomain_to_realm(data)
if realm is not None:
raise ValidationError(subdomain_unavailable(data))
return data
class ToSForm(forms.Form):
terms = forms.BooleanField(required=True)
@@ -89,8 +107,11 @@ class HomepageForm(forms.Form):
def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
self.domain = kwargs.get("domain")
self.subdomain = kwargs.get("subdomain")
if "domain" in kwargs:
del kwargs["domain"]
if "subdomain" in kwargs:
del kwargs["subdomain"]
super(HomepageForm, self).__init__(*args, **kwargs)
def clean_email(self):
@@ -106,6 +127,12 @@ class HomepageForm(forms.Form):
if completely_open(self.domain):
return data
# If the subdomain encodes a complete open realm, pass
subdomain_realm = resolve_subdomain_to_realm(self.subdomain)
if (subdomain_realm is not None and
completely_open(subdomain_realm.domain)):
return data
# If no realm is specified, fail
realm = get_valid_realm(data)
if realm is None:
@@ -191,4 +218,8 @@ Please contact %s to reactivate this group.""" % (
settings.ZULIP_ADMINISTRATOR)
raise ValidationError(mark_safe(error_msg))
if not check_subdomain(get_subdomain(self.request), user_profile.realm.subdomain):
logging.warning("User %s attempted to password login to wrong subdomain %s" %
(user_profile.email, get_subdomain(self.request)))
raise ValidationError(mark_safe(WRONG_SUBDOMAIN_ERROR))
return email

View File

@@ -1929,12 +1929,12 @@ def do_change_stream_description(realm, stream_name, new_description):
value=new_description)
send_event(event, stream_user_ids(stream))
def do_create_realm(domain, name, restricted_to_domain=True):
# type: (text_type, text_type, bool) -> Tuple[Realm, bool]
def do_create_realm(domain, name, restricted_to_domain=True, subdomain=None):
# type: (text_type, text_type, bool, Optional[text_type]) -> Tuple[Realm, bool]
realm = get_realm(domain)
created = not realm
if created:
realm = Realm(domain=domain, name=name,
realm = Realm(domain=domain, name=name, subdomain=subdomain,
restricted_to_domain=restricted_to_domain)
realm.save()

View File

@@ -384,18 +384,24 @@ class ZulipTestCase(TestCase):
{'email': username + "@" + domain})
return self.submit_reg_form_for_user(username, password, domain=domain)
def submit_reg_form_for_user(self, username, password, domain="zulip.com"):
# type: (text_type, text_type, text_type) -> HttpResponse
def submit_reg_form_for_user(self, username, password, domain="zulip.com",
realm_name=None, realm_subdomain=None, **kwargs):
# type: (text_type, text_type, text_type, Optional[text_type], Optional[text_type], **Any) -> HttpResponse
"""
Stage two of the two-step registration process.
If things are working correctly the account should be fully
registered after this call.
You can pass the HTTP_HOST variable for subdomains via kwargs.
"""
return self.client_post('/accounts/register/',
{'full_name': username, 'password': password,
'realm_name': realm_name,
'realm_subdomain': realm_subdomain,
'key': find_key_by_email(username + '@' + domain),
'terms': True})
'terms': True},
**kwargs)
def get_confirmation_url_from_outbox(self, email_address, path_pattern="(\S+)>"):
# type: (text_type, text_type) -> text_type

View File

@@ -13,6 +13,7 @@ import os
from time import sleep
from django.conf import settings
from django.http import HttpRequest
from six.moves import range
from zerver.lib.str_utils import force_text
@@ -184,3 +185,21 @@ def query_chunker(queries, id_collector=None, chunk_size=1000, db_chunk_size=Non
id_collector.update(tup_ids)
yield [row for row_id, i, row in tup_chunk]
def get_subdomain(request):
# type: (HttpRequest) -> text_type
domain = request.get_host().lower()
index = domain.find("." + settings.EXTERNAL_HOST)
if index == -1:
return ""
subdomain = domain[0:index]
return subdomain
def check_subdomain(realm_subdomain, user_subdomain):
# type: (text_type, text_type) -> bool
if settings.REALMS_HAVE_SUBDOMAINS and realm_subdomain is not None:
if (realm_subdomain == "" and user_subdomain is None):
return True
if realm_subdomain != user_subdomain:
return False
return True

View File

@@ -10,16 +10,18 @@ from zerver.lib.response import json_error
from zerver.lib.request import JsonableError
from django.db import connection
from django.http import HttpRequest, HttpResponse
from zerver.lib.utils import statsd
from zerver.lib.utils import statsd, get_subdomain
from zerver.lib.queue import queue_json_publish
from zerver.lib.cache import get_remote_cache_time, get_remote_cache_requests
from zerver.lib.bugdown import get_bugdown_time, get_bugdown_requests
from zerver.models import flush_per_request_caches
from zerver.models import flush_per_request_caches, resolve_subdomain_to_realm
from zerver.exceptions import RateLimited
from django.contrib.sessions.middleware import SessionMiddleware
from django.views.csrf import csrf_failure as html_csrf_failure
from django.utils.cache import patch_vary_headers
from django.utils.http import cookie_date
from zproject.jinja2 import render_to_response
from django.shortcuts import redirect
import logging
import time
@@ -346,6 +348,17 @@ class FlushDisplayRecipientCache(object):
class SessionHostDomainMiddleware(SessionMiddleware):
def process_response(self, request, response):
# type: (HttpRequest, HttpResponse) -> HttpResponse
if settings.REALMS_HAVE_SUBDOMAINS:
if (not request.path.startswith("/static/") and not request.path.startswith("/api/")
and not request.path.startswith("/json/")):
subdomain = get_subdomain(request)
if (request.get_host() == "127.0.0.1:9991" or request.get_host() == "localhost:9991"):
return redirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
settings.EXTERNAL_HOST))
if subdomain != "":
realm = resolve_subdomain_to_realm(subdomain)
if (realm is None):
return render_to_response("zerver/invalid_realm.html")
"""
If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie.
@@ -371,9 +384,15 @@ class SessionHostDomainMiddleware(SessionMiddleware):
if response.status_code != 500:
request.session.save()
host = request.get_host().split(':')[0]
session_cookie_domain = settings.SESSION_COOKIE_DOMAIN
if host.endswith(".e.zulip.com"):
session_cookie_domain = ".e.zulip.com"
# The subdomains feature overrides the
# SESSION_COOKIE_DOMAIN setting, since the setting
# is a fixed value and with subdomains enabled,
# the session cookie domain has to vary with the
# subdomain.
if settings.REALMS_HAVE_SUBDOMAINS:
session_cookie_domain = host
response.set_cookie(settings.SESSION_COOKIE_NAME,
request.session.session_key, max_age=max_age,
expires=expires, domain=session_cookie_domain,

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
def set_subdomain_of_default_realm(apps, schema_editor):
# type: (StateApps, DatabaseSchemaEditor) -> None
if settings.DEVELOPMENT:
Realm = apps.get_model('zerver', 'Realm')
try:
default_realm = Realm.objects.get(domain="zulip.com")
except ObjectDoesNotExist:
default_realm = None
if default_realm is not None:
default_realm.subdomain = "zulip"
default_realm.save()
class Migration(migrations.Migration):
dependencies = [
('zerver', '0028_userprofile_tos_version'),
]
operations = [
migrations.AddField(
model_name='realm',
name='subdomain',
field=models.CharField(max_length=40, unique=True, null=True),
),
migrations.RunPython(set_subdomain_of_default_realm)
]

View File

@@ -140,6 +140,7 @@ class Realm(ModelReprMixin, models.Model):
# name is the user-visible identifier for the realm. It has no required
# structure.
name = models.CharField(max_length=40, null=True) # type: Optional[text_type]
subdomain = models.CharField(max_length=40, null=True, unique=True) # type: Optional[text_type]
restricted_to_domain = models.BooleanField(default=True) # type: bool
invite_required = models.BooleanField(default=False) # type: bool
invite_by_admins_only = models.BooleanField(default=False) # type: bool
@@ -199,11 +200,16 @@ class Realm(ModelReprMixin, models.Model):
@property
def uri(self):
# type: () -> str
if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
return '%s%s.%s' % (settings.EXTERNAL_URI_SCHEME,
self.subdomain, settings.EXTERNAL_HOST)
return settings.SERVER_URI
@property
def host(self):
# type: () -> str
if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
return "%s.%s" % (self.subdomain, settings.EXTERNAL_HOST)
return settings.EXTERNAL_HOST
@property
@@ -258,6 +264,13 @@ def resolve_email_to_domain(email):
domain = alias.realm.domain
return domain
def resolve_subdomain_to_realm(subdomain):
# type: (text_type) -> Optional[Realm]
try:
return Realm.objects.get(subdomain=subdomain)
except Realm.DoesNotExist:
return None
# Is a user with the given email address allowed to be in the given realm?
# (This function does not check whether the user has been invited to the realm.
# So for invite-only realms, this is the test for whether a user can be invited,

View File

@@ -3,7 +3,8 @@ from __future__ import absolute_import
import os
from django.test import TestCase
from django.conf import settings
from django.test import TestCase, override_settings
from typing import Any
from zproject.settings import DEPLOY_ROOT
@@ -17,9 +18,27 @@ class IntegrationTest(TestCase):
for integration in INTEGRATIONS.values():
self.assertTrue(os.path.isfile(os.path.join(DEPLOY_ROOT, integration.logo)))
@override_settings(REALMS_HAVE_SUBDOMAINS=False)
def test_api_url_view_base(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
add_api_uri_context(context, HostRequestMock())
self.assertEqual(context["external_api_path_subdomain"], "localhost:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://localhost:9991/api")
self.assertEqual(context["external_api_path_subdomain"], "zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://zulipdev.com:9991/api")
@override_settings(REALMS_HAVE_SUBDOMAINS=True)
def test_api_url_view_subdomains_base(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
add_api_uri_context(context, HostRequestMock())
self.assertEqual(context["external_api_path_subdomain"], "yourZulipDomain.zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://yourZulipDomain.zulipdev.com:9991/api")
@override_settings(REALMS_HAVE_SUBDOMAINS=True, EXTERNAL_HOST="zulipdev.com")
def test_api_url_view_subdomains_full(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
request = HostRequestMock(host="mysubdomain.zulipdev.com")
add_api_uri_context(context, request)
self.assertEqual(context["external_api_path_subdomain"], "mysubdomain.zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://mysubdomain.zulipdev.com:9991/api")

View File

@@ -734,3 +734,54 @@ class UserSignUpTest(ZulipTestCase):
self.assertEqual(user_profile.default_language, realm.default_language)
from django.core.mail import outbox
outbox.pop()
def test_create_realm_with_subdomain(self):
# type: () -> None
username = "user1"
password = "test"
domain = "test.com"
email = "user1@test.com"
subdomain = "test"
realm_name = "Test"
# Make sure the realm does not exist
self.assertIsNone(get_realm(domain))
with self.settings(REALMS_HAVE_SUBDOMAINS=True), self.settings(OPEN_REALM_CREATION=True):
# Create new realm with the email
result = self.client_post('/create_realm/', {'email': email})
self.assertEquals(result.status_code, 302)
self.assertTrue(result["Location"].endswith(
"/accounts/send_confirm/%s@%s" % (username, domain)))
result = self.client_get(result["Location"])
self.assert_in_response("Check your email so we can get started.", result)
# Visit the confirmation link.
from django.core.mail import outbox
for message in reversed(outbox):
if email in message.to:
confirmation_link_pattern = re.compile(settings.EXTERNAL_HOST + "(\S+)>")
confirmation_url = confirmation_link_pattern.search(
message.body).groups()[0]
break
else:
raise ValueError("Couldn't find a confirmation email.")
result = self.client_get(confirmation_url)
self.assertEquals(result.status_code, 200)
result = self.submit_reg_form_for_user(username,
password,
domain=domain,
realm_name=realm_name,
realm_subdomain=subdomain,
# Pass HTTP_HOST for the target subdomain
HTTP_HOST=subdomain + ".testserver")
self.assertEquals(result.status_code, 302)
# Make sure the realm is created
realm = get_realm(domain)
self.assertIsNotNone(realm)
self.assertEqual(realm.domain, domain)
self.assertEqual(realm.name, realm_name)
self.assertEqual(realm.subdomain, subdomain)
self.assertEqual(get_user_profile_by_email(email).realm, realm)

View File

@@ -25,7 +25,7 @@ from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \
get_stream, UserPresence, get_recipient, \
split_email_to_domain, resolve_email_to_domain, email_to_username, get_realm, \
completely_open, get_unique_open_realm, remote_user_to_email, email_allowed_for_realm, \
get_cross_realm_users
get_cross_realm_users, resolve_subdomain_to_realm
from zerver.lib.actions import do_change_password, do_change_full_name, do_change_is_admin, \
do_activate_user, do_create_user, do_create_realm, set_default_streams, \
internal_send_message, update_user_presence, do_events_register, \
@@ -49,7 +49,7 @@ from zerver.lib.avatar import avatar_url
from zerver.lib.i18n import get_language_list, get_language_name, \
get_language_list_for_templates
from zerver.lib.response import json_success, json_error
from zerver.lib.utils import statsd
from zerver.lib.utils import statsd, get_subdomain
from version import ZULIP_VERSION
from zproject.backends import password_auth_enabled, dev_auth_enabled, google_auth_enabled
@@ -198,7 +198,12 @@ def accounts_register(request):
if realm_creation:
domain = split_email_to_domain(email)
realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
if settings.REALMS_HAVE_SUBDOMAINS:
realm = do_create_realm(domain, form.cleaned_data['realm_name'],
subdomain=form.cleaned_data['realm_subdomain'])[0]
else:
realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
set_default_streams(realm, settings.DEFAULT_NEW_REALM_STREAMS)
full_name = form.cleaned_data['full_name']
@@ -225,11 +230,21 @@ def accounts_register(request):
# This logs you in using the ZulipDummyBackend, since honestly nothing
# more fancy than this is required.
login(request, authenticate(username=user_profile.email, use_dummy_backend=True))
login(request, authenticate(username=user_profile.email,
realm_subdomain=realm.subdomain,
use_dummy_backend=True))
if first_in_realm:
do_change_is_admin(user_profile, True)
return HttpResponseRedirect(reverse('zerver.views.initial_invite_page'))
invite_url = reverse('zerver.views.initial_invite_page')
if (realm_creation and settings.REALMS_HAVE_SUBDOMAINS):
invite_url = "%s%s.%s%s" % (
settings.EXTERNAL_URI_SCHEME,
form.cleaned_data['realm_subdomain'],
settings.EXTERNAL_HOST,
reverse('zerver.views.initial_invite_page')
)
return HttpResponseRedirect(invite_url)
else:
return HttpResponseRedirect(reverse('zerver.views.home'))
@@ -244,6 +259,7 @@ def accounts_register(request):
# but for the registration form, there is no logged in user yet, so
# we have to set it here.
'creating_new_team': realm_creation,
'realms_have_subdomains': settings.REALMS_HAVE_SUBDOMAINS,
'password_auth_enabled': password_auth_enabled(realm),
},
request=request)
@@ -316,10 +332,11 @@ def get_invitee_emails_set(invitee_emails_raw):
def create_homepage_form(request, user_info=None):
# type: (HttpRequest, Optional[Dict[str, Any]]) -> HomepageForm
if user_info:
return HomepageForm(user_info, domain=request.session.get("domain"))
return HomepageForm(user_info, domain=request.session.get("domain"),
subdomain=get_subdomain(request))
# An empty fields dict is not treated the same way as not
# providing it.
return HomepageForm(domain=request.session.get("domain"))
return HomepageForm(domain=request.session.get("domain"), subdomain=get_subdomain(request))
def maybe_send_to_registration(request, email, full_name=''):
# type: (HttpRequest, text_type, text_type) -> HttpResponse
@@ -362,6 +379,11 @@ def login_or_register_remote_user(request, remote_username, user_profile, full_n
return maybe_send_to_registration(request, remote_user_to_email(remote_username), full_name)
else:
login(request, user_profile)
if settings.OPEN_REALM_CREATION and user_profile.realm.subdomain is not None:
return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
user_profile.realm.subdomain,
settings.EXTERNAL_HOST))
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host()))
@@ -372,7 +394,7 @@ def remote_user_sso(request):
except KeyError:
raise JsonableError(_("No REMOTE_USER set."))
user_profile = authenticate(remote_user=remote_user)
user_profile = authenticate(remote_user=remote_user, realm_subdomain=get_subdomain(request))
return login_or_register_remote_user(request, remote_user, user_profile)
@csrf_exempt
@@ -401,7 +423,9 @@ def remote_user_jwt(request):
# We do all the authentication we need here (otherwise we'd have to
# duplicate work), but we need to call authenticate with some backend so
# that the request.backend attribute gets set.
user_profile = authenticate(username=email, use_dummy_backend=True)
user_profile = authenticate(username=email,
realm_subdomain=get_subdomain(request),
use_dummy_backend=True)
except (jwt.DecodeError, jwt.ExpiredSignature):
raise JsonableError(_("Bad JSON web token signature"))
except KeyError:
@@ -503,7 +527,9 @@ def finish_google_oauth2(request):
logging.error('Google oauth2 account email not found: %s' % (body,))
return HttpResponse(status=400)
email_address = email['value']
user_profile = authenticate(username=email_address, use_dummy_backend=True)
user_profile = authenticate(username=email_address,
realm_subdomain=get_subdomain(request),
use_dummy_backend=True)
return login_or_register_remote_user(request, email_address, user_profile, full_name)
def login_page(request, **kwargs):
@@ -536,10 +562,16 @@ def dev_direct_login(request, **kwargs):
# This check is probably not required, since authenticate would fail without an enabled DevAuthBackend.
raise Exception('Direct login not supported.')
email = request.POST['direct_email']
user_profile = authenticate(username=email)
user_profile = authenticate(username=email, realm_subdomain=get_subdomain(request))
if user_profile is None:
raise Exception("User cannot login")
login(request, user_profile)
if settings.OPEN_REALM_CREATION and settings.DEVELOPMENT:
if user_profile.realm.subdomain is not None:
return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
user_profile.realm.subdomain,
settings.EXTERNAL_HOST))
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host()))
@@ -555,7 +587,9 @@ def api_dev_fetch_api_key(request, username=REQ()):
if not dev_auth_enabled() or settings.PRODUCTION:
return json_error(_("Dev environment not enabled."))
return_data = {} # type: Dict[str, bool]
user_profile = authenticate(username=username, return_data=return_data)
user_profile = authenticate(username=username,
realm_subdomain=get_subdomain(request),
return_data=return_data)
if return_data.get("inactive_realm") == True:
return json_error(_("Your realm has been deactivated."),
data={"reason": "realm deactivated"}, status=403)
@@ -660,7 +694,8 @@ def send_registration_completion_email(email, request, realm_creation=False):
context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS}
return Confirmation.objects.send_confirmation(prereg_user, email,
additional_context=context)
additional_context=context,
host=request.get_host())
def redirect_to_email_login_url(email):
# type: (str) -> HttpResponseRedirect
@@ -1003,9 +1038,14 @@ def api_fetch_api_key(request, username=REQ(), password=REQ()):
# type: (HttpRequest, str, str) -> HttpResponse
return_data = {} # type: Dict[str, bool]
if username == "google-oauth2-token":
user_profile = authenticate(google_oauth2_token=password, return_data=return_data)
user_profile = authenticate(google_oauth2_token=password,
realm_subdomain=get_subdomain(request),
return_data=return_data)
else:
user_profile = authenticate(username=username, password=password, return_data=return_data)
user_profile = authenticate(username=username,
password=password,
realm_subdomain=get_subdomain(request),
return_data=return_data)
if return_data.get("inactive_user") == True:
return json_error(_("Your account has been disabled."),
data={"reason": "user disable"}, status=403)
@@ -1039,7 +1079,8 @@ def api_get_auth_backends(request):
def json_fetch_api_key(request, user_profile, password=REQ(default='')):
# type: (HttpRequest, UserProfile, str) -> HttpResponse
if password_auth_enabled(user_profile.realm):
if not authenticate(username=user_profile.email, password=password):
if not authenticate(username=user_profile.email, password=password,
realm_subdomain=get_subdomain(request)):
return json_error(_("Your username or password is incorrect."))
return json_success({"api_key": user_profile.api_key})

View File

@@ -9,15 +9,31 @@ import ujson
from zerver.lib import bugdown
from zerver.lib.integrations import INTEGRATIONS
from zerver.lib.utils import get_subdomain
from zproject.jinja2 import render_to_response
def add_api_uri_context(context, request):
# type: (Dict[str, Any], HttpRequest) -> None
external_api_path_subdomain = settings.EXTERNAL_API_PATH
external_api_uri_subdomain = settings.EXTERNAL_API_URI
if settings.REALMS_HAVE_SUBDOMAINS:
subdomain = get_subdomain(request)
if subdomain:
display_subdomain = subdomain
html_settings_links = True
else:
display_subdomain = 'yourZulipDomain'
html_settings_links = False
external_api_path_subdomain = '%s.%s' % (display_subdomain,
settings.EXTERNAL_API_PATH)
else:
external_api_path_subdomain = settings.EXTERNAL_API_PATH
html_settings_links = True
external_api_uri_subdomain = '%s%s' % (settings.EXTERNAL_URI_SCHEME,
external_api_path_subdomain)
context['external_api_path_subdomain'] = external_api_path_subdomain
context['external_api_uri_subdomain'] = external_api_uri_subdomain
context["html_settings_links"] = html_settings_links
class ApiURLView(TemplateView):
def get_context_data(self, **kwargs):
@@ -26,7 +42,6 @@ class ApiURLView(TemplateView):
add_api_uri_context(context, self.request)
return context
class APIView(ApiURLView):
template_name = 'zerver/api.html'
@@ -40,8 +55,12 @@ class IntegrationView(ApiURLView):
alphabetical_sorted_integration = OrderedDict(sorted(INTEGRATIONS.items()))
context['integrations_dict'] = alphabetical_sorted_integration
settings_html = '<a href="../#settings">Zulip settings page</a>'
subscriptions_html = '<a target="_blank" href="../#subscriptions">subscriptions page</a>'
if context["html_settings_links"]:
settings_html = '<a href="../#settings">Zulip settings page</a>'
subscriptions_html = '<a target="_blank" href="../#subscriptions">subscriptions page</a>'
else:
settings_html = 'Zulip settings page'
subscriptions_html = 'subscriptions page'
context['settings_html'] = settings_html
context['subscriptions_html'] = subscriptions_html

View File

@@ -123,7 +123,7 @@ class Command(BaseCommand):
clear_database()
# Create our two default realms
zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev")
zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev", subdomain="zulip")
if options["test_suite"]:
Realm.objects.create(domain="mit.edu")
realms = {} # type: Dict[text_type, Realm]

View File

@@ -22,6 +22,7 @@ from social.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
GithubTeamOAuth2
from social.exceptions import AuthFailed
from django.contrib.auth import authenticate
from zerver.lib.utils import check_subdomain
def password_auth_enabled(realm):
# type: (Realm) -> bool
@@ -115,6 +116,11 @@ class SocialAuthMixin(ZulipAuthMixin):
return_data["inactive_realm"] = True
return None
if not check_subdomain(kwargs.get("realm_subdomain"),
user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile
def process_do_auth(self, user_profile, *args, **kwargs):
@@ -142,10 +148,14 @@ class ZulipDummyBackend(ZulipAuthMixin):
"""
Used when we want to log you in but we don't know which backend to use.
"""
def authenticate(self, username=None, use_dummy_backend=False):
# type: (Optional[str], bool) -> Optional[UserProfile]
def authenticate(self, username=None, realm_subdomain=None, use_dummy_backend=False):
# type: (Optional[text_type], Optional[text_type], bool) -> Optional[UserProfile]
if use_dummy_backend:
return common_get_active_user_by_email(username)
user_profile = common_get_active_user_by_email(username)
if user_profile is None:
return None
if check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return user_profile
return None
class EmailAuthBackend(ZulipAuthMixin):
@@ -155,9 +165,8 @@ class EmailAuthBackend(ZulipAuthMixin):
Allows a user to sign in using an email/password pair rather than
a username/password pair.
"""
def authenticate(self, username=None, password=None, return_data=None):
# type: (Optional[text_type], Optional[str], Optional[Dict[str, Any]]) -> Optional[UserProfile]
def authenticate(self, username=None, password=None, realm_subdomain=None, return_data=None):
# type: (Optional[text_type], Optional[str], Optional[text_type], Optional[Dict[str, Any]]) -> Optional[UserProfile]
""" Authenticate a user based on email address as the user name. """
if username is None or password is None:
# Return immediately. Otherwise we will look for a SQL row with
@@ -173,7 +182,11 @@ class EmailAuthBackend(ZulipAuthMixin):
return_data['password_auth_disabled'] = True
return None
if user_profile.check_password(password):
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile
return None
class GoogleMobileOauth2Backend(ZulipAuthMixin):
"""
@@ -186,8 +199,8 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
https://developers.google.com/accounts/docs/CrossClientAuth#offlineAccess
"""
def authenticate(self, google_oauth2_token=None, return_data=dict()):
# type: (Optional[str], Dict[str, Any]) -> Optional[UserProfile]
def authenticate(self, google_oauth2_token=None, realm_subdomain=None, return_data={}):
# type: (Optional[str], Optional[text_type], Dict[str, Any]) -> Optional[UserProfile]
try:
token_payload = googleapiclient.verify_id_token(google_oauth2_token, settings.GOOGLE_CLIENT_ID)
except AppIdentityError:
@@ -204,20 +217,25 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
if user_profile.realm.deactivated:
return_data["inactive_realm"] = True
return None
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile
else:
return_data["valid_attestation"] = False
class ZulipRemoteUserBackend(RemoteUserBackend):
create_unknown_user = False
def authenticate(self, remote_user):
# type: (str) -> Optional[UserProfile]
def authenticate(self, remote_user, realm_subdomain=None):
# type: (str, Optional[text_type]) -> Optional[UserProfile]
if not remote_user:
return None
email = remote_user_to_email(remote_user)
return common_get_active_user_by_email(email)
user = common_get_active_user_by_email(email)
if user is not None and check_subdomain(realm_subdomain, user.realm.subdomain):
return user
return None
class ZulipLDAPException(Exception):
pass
@@ -257,11 +275,16 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
return username
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
def authenticate(self, username, password, return_data=None):
# type: (text_type, str, Optional[Dict[str, Any]]) -> Optional[str]
def authenticate(self, username, password, realm_subdomain=None, return_data=None):
# type: (text_type, str, Optional[text_type], Optional[Dict[str, Any]]) -> Optional[str]
try:
username = self.django_to_ldap_username(username)
return ZulipLDAPAuthBackendBase.authenticate(self, username, password)
user_profile = ZulipLDAPAuthBackendBase.authenticate(self, username, password)
if user_profile is None:
return None
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return None
return user_profile
except Realm.DoesNotExist:
return None
except ZulipLDAPException:
@@ -292,16 +315,15 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
# Just like ZulipLDAPAuthBackend, but doesn't let you log in.
class ZulipLDAPUserPopulator(ZulipLDAPAuthBackendBase):
def authenticate(self, username, password):
# type: (text_type, str) -> None
def authenticate(self, username, password, realm_subdomain=None):
# type: (text_type, str, Optional[text_type]) -> None
return None
class DevAuthBackend(ZulipAuthMixin):
# Allow logging in as any user without a password.
# This is used for convenience when developing Zulip.
def authenticate(self, username, return_data=None):
# type: (text_type, Optional[Dict[str, Any]]) -> UserProfile
def authenticate(self, username, realm_subdomain=None, return_data=None):
# type: (text_type, Optional[text_type], Optional[Dict[str, Any]]) -> UserProfile
return common_get_active_user_by_email(username, return_data=return_data)
class GitHubAuthBackend(SocialAuthMixin, GithubOAuth2):

View File

@@ -4,8 +4,8 @@
from .prod_settings_template import *
LOCAL_UPLOADS_DIR = 'var/uploads'
EXTERNAL_HOST = 'localhost:9991'
ALLOWED_HOSTS = ['localhost']
EXTERNAL_HOST = 'zulipdev.com:9991'
ALLOWED_HOSTS = ['*']
AUTHENTICATION_BACKENDS = ('zproject.backends.DevAuthBackend',)
# Add some of the below if you're testing other backends
# AUTHENTICATION_BACKENDS = ('zproject.backends.EmailAuthBackend',
@@ -20,6 +20,9 @@ EXTRA_INSTALLED_APPS = ["zilencer", "analytics"]
# Disable Camo in development
CAMO_URI = ''
OPEN_REALM_CREATION = True
# Default to subdomains disabled in development until we can update
# the development documentation to make sense with subdomains.
REALMS_HAVE_SUBDOMAINS = False
TERMS_OF_SERVICE = 'zproject/terms.md.template'
SAVE_FRONTEND_STACKTRACES = True

View File

@@ -158,6 +158,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '',
'VERBOSE_SUPPORT_OFFERS': False,
'STATSD_HOST': '',
'OPEN_REALM_CREATION': False,
'REALMS_HAVE_SUBDOMAINS': False,
'REMOTE_POSTGRES_HOST': '',
'REMOTE_POSTGRES_SSLMODE': '',
# Default GOOGLE_CLIENT_ID to the value needed for Android auth to work

View File

@@ -82,6 +82,8 @@ LOCAL_UPLOADS_DIR = 'var/test_uploads'
S3_KEY = 'test-key'
S3_SECRET_KEY = 'test-secret-key'
S3_AUTH_UPLOADS_BUCKET = 'test-authed-bucket'
EXTERNAL_HOST = os.getenv('EXTERNAL_HOST', "testserver")
REALMS_HAVE_SUBDOMAINS = bool(os.getenv('REALMS_HAVE_SUBDOMAINS', False))
# Test Custom TOS template rendering
TERMS_OF_SERVICE = 'corporate/terms.md'